Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¶graph, 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(¶graph, text_styling, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¶graph, 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, ¶graph_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(¶graph, 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)
|
||||
}
|
||||
}
|
||||
@@ -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(¶graph, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
¶graph_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 }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Consistent bounds definitions for rich-text blocks.
|
||||
//!
|
||||
//! The terminology is loosely based on the [alternative CSS box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model#parts_of_a_box).
|
||||
//!
|
||||
//! * The **content box** is the rectangle containing a block's content, without any margins or
|
||||
//! padding.
|
||||
//! * The **visible box** is the rectangle containing a block's content, padding, and borders -
|
||||
//! everything that's visually part of the block.
|
||||
//! * The **reserved box** is the rectangle containing a block's content, padding, borders, and
|
||||
//! margin - all space reserved for the block.
|
||||
|
||||
use warpui::{
|
||||
geometry::{
|
||||
rect::RectF,
|
||||
vector::{Vector2F, vec2f},
|
||||
},
|
||||
units::Pixels,
|
||||
};
|
||||
|
||||
use super::BlockSpacing;
|
||||
|
||||
/// The origin of a block's content box. This is relative to the buffer origin. To convert it
|
||||
/// to an on-screen point, use `RenderContext::content_to_screen`.
|
||||
pub fn content_origin(y_offset: Pixels, spacing: &BlockSpacing) -> Vector2F {
|
||||
vec2f(
|
||||
spacing.left_offset().as_f32(),
|
||||
(y_offset + spacing.top_offset()).as_f32(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The content box for a block, given its:
|
||||
/// * y-offset relative to the start of the buffer
|
||||
/// * Content size, in pixels
|
||||
/// * Spacing
|
||||
///
|
||||
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
|
||||
/// `RenderContext::content_rect_to_screen`.
|
||||
pub fn content_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
|
||||
RectF::new(content_origin(y_offset, spacing), content_size)
|
||||
}
|
||||
|
||||
/// The origin of a block's visible box. This is relative to the buffer origin. To convert it
|
||||
/// to an on-screen point, use `RenderContext::content_to_screen`.
|
||||
pub fn visible_origin(y_offset: Pixels, spacing: &BlockSpacing) -> Vector2F {
|
||||
vec2f(
|
||||
spacing.margin.left(),
|
||||
y_offset.as_f32() + spacing.margin.top(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The visible box for a block, given its:
|
||||
/// * y-offset relative to the start of the buffer
|
||||
/// * Content size, in pixels
|
||||
/// * Spacing
|
||||
///
|
||||
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
|
||||
/// `RenderContext::content_rect_to_screen`.
|
||||
pub fn visible_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
|
||||
RectF::new(
|
||||
visible_origin(y_offset, spacing),
|
||||
content_size
|
||||
+ vec2f(
|
||||
spacing.padding.left() + spacing.padding.right(),
|
||||
spacing.padding.top() + spacing.padding.bottom(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// The origin of a block's reserved box. This is relative to the buffer origin. To convert it
|
||||
/// to an on-screen point, use `RenderContext::content_to_screen`.
|
||||
pub fn reserved_origin(y_offset: Pixels) -> Vector2F {
|
||||
vec2f(0., y_offset.as_f32())
|
||||
}
|
||||
|
||||
/// The reserved box for a block, given its:
|
||||
/// * y-offset relative to the start of the buffer
|
||||
/// * Content size, in pixels
|
||||
/// * Spacing
|
||||
///
|
||||
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
|
||||
/// `RenderContext::content_rect_to_screen`.
|
||||
pub fn reserved_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
|
||||
RectF::new(
|
||||
reserved_origin(y_offset),
|
||||
content_size
|
||||
+ vec2f(
|
||||
spacing.x_axis_offset().as_f32(),
|
||||
spacing.y_axis_offset().as_f32(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use std::fmt;
|
||||
|
||||
use sum_tree::SumTree;
|
||||
|
||||
use super::{BlockItem, LayoutSummary, RenderState};
|
||||
|
||||
/// Extension trait for types with verbose descriptive formatting.
|
||||
pub trait Describe {
|
||||
/// Describe this item into the given formatter.
|
||||
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result;
|
||||
|
||||
/// Describe this item.
|
||||
fn describe(&self) -> Description<'_, Self> {
|
||||
Description(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Description<'a, T: ?Sized>(&'a T);
|
||||
|
||||
impl<T: Describe + ?Sized> fmt::Display for Description<'_, T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
self.0.describe_to(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Describe for RenderState {
|
||||
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let viewport_start = self.viewport.scroll_top().as_f32() as f64;
|
||||
let viewport_end = viewport_start + self.viewport.height().as_f32() as f64;
|
||||
writeln!(f, "Viewport: {viewport_start:.2}px to {viewport_end:.2}px")?;
|
||||
writeln!(f, "Selection: {}", self.selections())?;
|
||||
|
||||
let mut in_viewport = false;
|
||||
let content = self.content.borrow();
|
||||
let mut cursor = content.cursor::<(), LayoutSummary>();
|
||||
cursor.descend_to_first_item(&content, |_| true);
|
||||
while let Some(item) = cursor.item() {
|
||||
let start_summary = cursor.start();
|
||||
|
||||
let item_start = start_summary.height;
|
||||
let item_end = item_start + item.height().as_f32() as f64;
|
||||
|
||||
// Is this the end of the viewport?
|
||||
if item_start > viewport_end && in_viewport {
|
||||
in_viewport = false;
|
||||
writeln!(f, "============> VIEWPORT END <============")?;
|
||||
}
|
||||
|
||||
// Is this the start of the viewport?
|
||||
if item_end >= viewport_start && item_start <= viewport_end && !in_viewport {
|
||||
in_viewport = true;
|
||||
writeln!(f, "============> VIEWPORT START <============")?;
|
||||
}
|
||||
|
||||
writeln!(
|
||||
f,
|
||||
"-------- {:.2}px / {} characters --------",
|
||||
start_summary.height, start_summary.content_length
|
||||
)?;
|
||||
writeln!(f, " {}", item.describe())?;
|
||||
cursor.next();
|
||||
}
|
||||
|
||||
if in_viewport {
|
||||
writeln!(f, "============> VIEWPORT END <============")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Describe for SumTree<BlockItem> {
|
||||
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let mut cursor = self.cursor::<(), LayoutSummary>();
|
||||
cursor.descend_to_first_item(self, |_| true);
|
||||
while let Some(item) = cursor.item() {
|
||||
let summary = cursor.start();
|
||||
writeln!(
|
||||
f,
|
||||
"-------- {:.2}px / {} characters --------\n{}",
|
||||
summary.height,
|
||||
summary.content_length,
|
||||
item.describe()
|
||||
)?;
|
||||
cursor.next();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Describe for BlockItem {
|
||||
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
BlockItem::Paragraph(_) => f.write_str("Paragraph")?,
|
||||
BlockItem::TextBlock { .. } => f.write_str("Text Block")?,
|
||||
BlockItem::RunnableCodeBlock {
|
||||
code_block_type, ..
|
||||
} => write!(f, "Code Block - {code_block_type}",)?,
|
||||
BlockItem::MermaidDiagram { .. } => f.write_str("Mermaid Diagram")?,
|
||||
BlockItem::TemporaryBlock { .. } => f.write_str("Temporary Paragraph")?,
|
||||
BlockItem::TaskList {
|
||||
indent_level,
|
||||
complete,
|
||||
..
|
||||
} => write!(
|
||||
f,
|
||||
"Task List @ {indent_level} [{}]",
|
||||
if *complete { "X" } else { " " }
|
||||
)?,
|
||||
BlockItem::UnorderedList { indent_level, .. } => {
|
||||
write!(f, "Unordered List @ {indent_level}")?
|
||||
}
|
||||
BlockItem::OrderedList { indent_level, .. } => {
|
||||
write!(f, "Ordered List @ {indent_level}")?
|
||||
}
|
||||
BlockItem::Header { header_size, .. } => write!(f, "{header_size:?}")?,
|
||||
BlockItem::HorizontalRule(_) => f.write_str("Horizontal Rule")?,
|
||||
BlockItem::Image { alt_text, .. } => write!(f, "Image: {alt_text}")?,
|
||||
BlockItem::Table(laid_out_table) => write!(
|
||||
f,
|
||||
"Table: {}x{}",
|
||||
laid_out_table.table.rows.len() + 1,
|
||||
laid_out_table.table.headers.len()
|
||||
)?,
|
||||
BlockItem::TrailingNewLine(_) => f.write_str("Trailing Newline")?,
|
||||
BlockItem::Embedded(_) => f.write_str("Embedded Item")?,
|
||||
BlockItem::Hidden { .. } => f.write_str("Hidden")?,
|
||||
}
|
||||
|
||||
write!(
|
||||
f,
|
||||
" ({} characters, {} lines, {:.2}px tall)",
|
||||
self.content_length(),
|
||||
self.lines(),
|
||||
self.height()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Hit-testing implementation for the rendering model.
|
||||
|
||||
use num_traits::SaturatingSub;
|
||||
use sum_tree::SeekBias;
|
||||
use warpui::units::{IntoPixels, Pixels};
|
||||
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use super::{
|
||||
BlockItem, Height, HitTestBlockType, LayoutSummary, ParagraphBlock, RenderState, bounds,
|
||||
positioned::{Positioned, PositionedCursor},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "location_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// A location within the editor, as resolved by hit-testing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Location {
|
||||
/// An entire block.
|
||||
Block {
|
||||
/// The starting character offset of the block (inclusive).
|
||||
start_offset: CharOffset,
|
||||
/// The ending character offset of the block (exclusive).
|
||||
end_offset: CharOffset,
|
||||
/// Type of the block that was hit.
|
||||
block_type: HitTestBlockType,
|
||||
},
|
||||
/// An exact location within the content space.
|
||||
Text {
|
||||
/// Offset of the hit character.
|
||||
char_offset: CharOffset,
|
||||
/// Whether or not we clamped to this location (for example, if the position
|
||||
/// was after the end of text on a line, or after the end of all text
|
||||
/// in the editor).
|
||||
clamped: bool,
|
||||
/// Cursor disposition for soft-wrapped text.
|
||||
wrap_direction: WrapDirection,
|
||||
/// The starting offset of the block that contains the hit character.
|
||||
block_start: CharOffset,
|
||||
link: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Location {
|
||||
/// The starting [`CharOffset`] of the block that was hit. All hits are within a single block.
|
||||
pub fn block_start(&self) -> CharOffset {
|
||||
match self {
|
||||
Location::Block { start_offset, .. } => *start_offset,
|
||||
Location::Text { block_start, .. } => *block_start,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With soft-wrapping, the end of one line and the start of the next have the
|
||||
/// same character offset. The `WrapDirection` indicates which one a location is
|
||||
/// at.
|
||||
///
|
||||
/// Suppose we have the line `longword`, soft-wrapped to
|
||||
/// ```text
|
||||
/// long
|
||||
/// word
|
||||
/// ```
|
||||
///
|
||||
/// Visually, after `g` and before `w` are two distinct locations. However, they
|
||||
/// have the same character offset, 4.
|
||||
///
|
||||
/// To represent the first location, we wrap up:
|
||||
/// ```text
|
||||
/// long|
|
||||
/// word
|
||||
/// ```
|
||||
///
|
||||
/// To represent the second, we wrap down:
|
||||
/// ```text
|
||||
/// long
|
||||
/// |word
|
||||
/// ```
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WrapDirection {
|
||||
/// Place the cursor at the end of the previous line.
|
||||
Up,
|
||||
/// Place the cursor at the start of the next line.
|
||||
#[default]
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct HitTestOptions {
|
||||
/// If true, clamp block-level selections to text selections. Currently, this only matters for
|
||||
/// a hit in the padding area of a code block. Normally, that's considered a hit on the block
|
||||
/// rather than its text.
|
||||
pub force_text_selection: bool,
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
/// Performs hit-testing on coordinates relative to the content origin
|
||||
/// (that is, non-viewported). The provided `options` configure how the hit-testing behaves.
|
||||
pub fn render_coordinates_to_location(
|
||||
&self,
|
||||
x: Pixels,
|
||||
y: Pixels,
|
||||
options: &HitTestOptions,
|
||||
) -> Location {
|
||||
let content = self.content.borrow();
|
||||
let mut block_cursor = content.cursor::<Height, LayoutSummary>();
|
||||
block_cursor.seek(&y.into(), SeekBias::Left);
|
||||
|
||||
let Some(block) = block_cursor.positioned_item() else {
|
||||
// If we're at the end of the editor, bias towards placing new text
|
||||
// on a new line.
|
||||
let char_offset = self.max_offset();
|
||||
log::debug!("Clamped to end: {char_offset}");
|
||||
return Location::Text {
|
||||
char_offset,
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: char_offset,
|
||||
link: None,
|
||||
};
|
||||
};
|
||||
|
||||
block.coordinates_to_location(x, y, options)
|
||||
}
|
||||
|
||||
/// Performs hit-testing on coordinates relative to the viewport origin.
|
||||
pub fn viewport_coordinates_to_location(
|
||||
&self,
|
||||
x: Pixels,
|
||||
y: Pixels,
|
||||
options: &HitTestOptions,
|
||||
) -> Location {
|
||||
self.render_coordinates_to_location(
|
||||
(x + self.viewport.scroll_left()).max(Pixels::zero()),
|
||||
(y + self.viewport.scroll_top()).max(Pixels::zero()),
|
||||
options,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Positioned<'a, BlockItem> {
|
||||
/// Resolve coordinates to a location, assuming they're within this block.
|
||||
/// The coordinates are all relative to the content origin.
|
||||
fn coordinates_to_location(&self, x: Pixels, y: Pixels, options: &HitTestOptions) -> Location {
|
||||
match self.item {
|
||||
BlockItem::Paragraph(paragraph) => self
|
||||
.paragraph(paragraph)
|
||||
.coordinate_to_location(self.unpad_x(x), y),
|
||||
BlockItem::TextBlock { paragraph_block } => {
|
||||
self.location_in_paragraph_block(x, y, self.text_block(paragraph_block))
|
||||
}
|
||||
BlockItem::TaskList { paragraph, .. } => self
|
||||
.task_list(paragraph)
|
||||
.coordinate_to_location(self.unpad_x(x), y),
|
||||
BlockItem::UnorderedList { paragraph, .. } => self
|
||||
.unordered_list(paragraph)
|
||||
.coordinate_to_location(self.unpad_x(x), y),
|
||||
BlockItem::OrderedList { paragraph, .. } => self
|
||||
.ordered_list(paragraph)
|
||||
.coordinate_to_location(self.unpad_x(x), y),
|
||||
BlockItem::RunnableCodeBlock {
|
||||
paragraph_block, ..
|
||||
} => {
|
||||
// To make text selection more ergonomic, any point on a line with text is
|
||||
// considered part of the block's text area, including padding. Points within the
|
||||
// padding above or below a code block's text are considered part of the block
|
||||
// itself (unless `options.force_text_selection` is true), which allows
|
||||
// clicking a block to select it.
|
||||
let text_origin = bounds::content_origin(self.start_y_offset, &self.style);
|
||||
let text_height_range =
|
||||
text_origin.y()..=text_origin.y() + paragraph_block.height().as_f32();
|
||||
|
||||
if options.force_text_selection || text_height_range.contains(&y.as_f32()) {
|
||||
// Note: we don't unpad `x` here because it's handled by `location_in_paragraph_block`.
|
||||
self.location_in_paragraph_block(x, y, self.code_block(paragraph_block))
|
||||
} else {
|
||||
Location::Block {
|
||||
start_offset: self.start_char_offset,
|
||||
end_offset: self.end_char_offset(),
|
||||
block_type: HitTestBlockType::Code,
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockItem::MermaidDiagram { .. } => {
|
||||
let _ = options;
|
||||
Location::Block {
|
||||
start_offset: self.start_char_offset,
|
||||
end_offset: self.end_char_offset(),
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
}
|
||||
}
|
||||
BlockItem::Header { paragraph, .. } => self
|
||||
.header(paragraph)
|
||||
.coordinate_to_location(self.unpad_x(x), y),
|
||||
BlockItem::Embedded(_) => Location::Block {
|
||||
start_offset: self.start_char_offset,
|
||||
end_offset: self.end_char_offset(),
|
||||
block_type: HitTestBlockType::Embedding,
|
||||
},
|
||||
BlockItem::Table(laid_out_table) => {
|
||||
let relative_x = (x.as_f32() - self.content_origin().x()).max(0.0);
|
||||
let relative_y = (y.as_f32() - self.content_origin().y()).max(0.0);
|
||||
let cell_offset = laid_out_table.coordinate_to_offset(
|
||||
relative_x + laid_out_table.scroll_left().as_f32(),
|
||||
relative_y,
|
||||
);
|
||||
let char_offset = self.start_char_offset + cell_offset;
|
||||
let link = laid_out_table.link_at_offset(cell_offset);
|
||||
Location::Text {
|
||||
char_offset,
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: self.start_char_offset,
|
||||
link,
|
||||
}
|
||||
}
|
||||
BlockItem::HorizontalRule { .. }
|
||||
| BlockItem::Image { .. }
|
||||
| BlockItem::TrailingNewLine(_)
|
||||
| BlockItem::TemporaryBlock { .. }
|
||||
| BlockItem::Hidden { .. } => Location::Text {
|
||||
char_offset: self.start_char_offset,
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: self.start_char_offset,
|
||||
link: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn location_in_paragraph_block(
|
||||
&self,
|
||||
x: Pixels,
|
||||
y: Pixels,
|
||||
paragraph_block: Positioned<'a, ParagraphBlock>,
|
||||
) -> Location {
|
||||
for paragraph in paragraph_block.paragraphs() {
|
||||
if paragraph.end_y_offset() > y {
|
||||
let mut location = paragraph.coordinate_to_location(self.unpad_x(x), y);
|
||||
// Adjust the paragraph-relative start offset to be the start of this block.
|
||||
if let Location::Text { block_start, .. } = &mut location {
|
||||
*block_start = self.start_char_offset;
|
||||
}
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
Location::Text {
|
||||
char_offset: self.end_char_offset().saturating_sub(&CharOffset::from(1)),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Up,
|
||||
block_start: self.start_char_offset,
|
||||
link: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove horizontal padding from an x-coordinate in order to hit-test within a padded
|
||||
/// paragraph.
|
||||
fn unpad_x(&self, x: Pixels) -> Pixels {
|
||||
(x - self.content_origin().x().into_pixels()).max(Pixels::zero())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
use crate::content::text::{FormattedTable, table_cell_offset_maps};
|
||||
use crate::{
|
||||
content::text::{BufferBlockStyle, CodeBlockType},
|
||||
render::model::{
|
||||
BlockItem, COMMAND_SPACING, CellLayout, ImageBlockConfig, LaidOutTable, Location,
|
||||
ParagraphBlock, RenderState, TableBlockConfig, TableStyle,
|
||||
location::{HitTestBlockType, HitTestOptions, WrapDirection},
|
||||
table_offset_map,
|
||||
test_utils::{
|
||||
TEST_STYLES, laid_out_paragraph, laid_out_unordered_lists, layout_paragraphs,
|
||||
},
|
||||
},
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use std::{cell::Cell, sync::Arc};
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use sum_tree::SumTree;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::text_layout::{CaretPosition, TextFrame};
|
||||
use warpui::units::IntoPixels;
|
||||
|
||||
fn test_table_layout() -> LaidOutTable {
|
||||
let source = "aaa\tbbb\nccc\tddd\n";
|
||||
let table = FormattedTable::from_internal_format(source);
|
||||
let cell_offset_maps = table_cell_offset_maps(&table, source);
|
||||
let offset_map = table_offset_map::TableOffsetMap::new(
|
||||
cell_offset_maps
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| cell.source_length().as_usize())
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let content_length = offset_map.total_length();
|
||||
let cell_layout = CellLayout {
|
||||
line_heights: vec![20.0],
|
||||
line_y_offsets: vec![0.0],
|
||||
line_char_ranges: vec![CharOffset::from(0)..CharOffset::from(3)],
|
||||
line_widths: vec![30.0],
|
||||
line_caret_positions: vec![vec![
|
||||
CaretPosition {
|
||||
position_in_line: 0.0,
|
||||
start_offset: 0,
|
||||
last_offset: 0,
|
||||
},
|
||||
CaretPosition {
|
||||
position_in_line: 10.0,
|
||||
start_offset: 1,
|
||||
last_offset: 1,
|
||||
},
|
||||
CaretPosition {
|
||||
position_in_line: 20.0,
|
||||
start_offset: 2,
|
||||
last_offset: 2,
|
||||
},
|
||||
]],
|
||||
};
|
||||
let cell_frame = Arc::new(TextFrame::mock("aaa"));
|
||||
|
||||
LaidOutTable {
|
||||
table,
|
||||
config: TableBlockConfig {
|
||||
width: 60.0.into_pixels(),
|
||||
spacing: Default::default(),
|
||||
style: TableStyle {
|
||||
border_color: ColorU::new(0, 0, 0, 255),
|
||||
header_background: ColorU::new(0, 0, 0, 255),
|
||||
cell_background: ColorU::new(0, 0, 0, 255),
|
||||
alternate_row_background: None,
|
||||
text_color: ColorU::new(0, 0, 0, 255),
|
||||
header_text_color: ColorU::new(0, 0, 0, 255),
|
||||
scrollbar_nonactive_thumb_color: ColorU::new(0, 0, 0, 255),
|
||||
scrollbar_active_thumb_color: ColorU::new(0, 0, 0, 255),
|
||||
font_family: FamilyId(0),
|
||||
font_size: 10.0,
|
||||
cell_padding: 0.0,
|
||||
outer_border: true,
|
||||
column_dividers: true,
|
||||
row_dividers: true,
|
||||
},
|
||||
},
|
||||
row_heights: vec![20.0.into_pixels(), 20.0.into_pixels()],
|
||||
column_widths: vec![30.0.into_pixels(), 30.0.into_pixels()],
|
||||
total_height: 40.0.into_pixels(),
|
||||
offset_map,
|
||||
content_length,
|
||||
cell_offset_maps,
|
||||
row_y_offsets: vec![0.0, 20.0, 40.0],
|
||||
col_x_offsets: vec![0.0, 30.0, 60.0],
|
||||
cell_text_frames: vec![
|
||||
vec![cell_frame.clone(), cell_frame.clone()],
|
||||
vec![cell_frame.clone(), cell_frame],
|
||||
],
|
||||
cell_layouts: vec![
|
||||
vec![cell_layout.clone(), cell_layout.clone()],
|
||||
vec![cell_layout.clone(), cell_layout],
|
||||
],
|
||||
cell_links: vec![vec![vec![], vec![]], vec![vec![], vec![]]],
|
||||
scroll_left: Cell::new(30.0.into_pixels()),
|
||||
scrollbar_interaction_state: Default::default(),
|
||||
horizontal_scroll_allowed: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_within_line() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
|
||||
model.set_content(SumTree::from_item(laid_out_paragraph(
|
||||
"Hello, world!\n",
|
||||
&TEST_STYLES,
|
||||
40.,
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
12.0.into_pixels(),
|
||||
4.3.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 1.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
20.0.into_pixels(),
|
||||
4.3.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 2.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_hit_testing_accounts_for_horizontal_scroll() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 30.0.into_pixels(), 60.0.into_pixels());
|
||||
model.set_content(SumTree::from_item(BlockItem::Table(Box::new(
|
||||
test_table_layout(),
|
||||
))));
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
0.0.into_pixels(),
|
||||
0.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 4.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_mermaid_block_uses_block_locations_even_with_forced_text_selection() {
|
||||
let width = 200.;
|
||||
let mut model = RenderState::new_for_test(
|
||||
TEST_STYLES.clone(),
|
||||
width.into_pixels(),
|
||||
160.0.into_pixels(),
|
||||
);
|
||||
let mermaid = BlockItem::MermaidDiagram {
|
||||
content_length: 14.into(),
|
||||
asset_source: AssetSource::Bundled {
|
||||
path: "bundled/svg/test.svg",
|
||||
},
|
||||
config: ImageBlockConfig {
|
||||
width: 120.0.into_pixels(),
|
||||
height: 40.0.into_pixels(),
|
||||
spacing: COMMAND_SPACING,
|
||||
},
|
||||
};
|
||||
let mermaid_height = mermaid.height().as_f32();
|
||||
model.set_content(SumTree::from_item(mermaid));
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
40.0.into_pixels(),
|
||||
(mermaid_height / 2.0).into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Block {
|
||||
start_offset: 0.into(),
|
||||
end_offset: 14.into(),
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
40.0.into_pixels(),
|
||||
1.0.into_pixels(),
|
||||
&HitTestOptions {
|
||||
force_text_selection: true,
|
||||
}
|
||||
),
|
||||
Location::Block {
|
||||
start_offset: 0.into(),
|
||||
end_offset: 14.into(),
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
40.0.into_pixels(),
|
||||
(mermaid_height - 1.0).into_pixels(),
|
||||
&HitTestOptions {
|
||||
force_text_selection: true,
|
||||
}
|
||||
),
|
||||
Location::Block {
|
||||
start_offset: 0.into(),
|
||||
end_offset: 14.into(),
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_within_list() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
|
||||
model.set_content(SumTree::from_item(laid_out_unordered_lists(
|
||||
"Hello, world!\n",
|
||||
&TEST_STYLES,
|
||||
40.,
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
18.0.into_pixels(),
|
||||
4.3.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 0.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
29.0.into_pixels(),
|
||||
4.3.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 1.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_empty_line() {
|
||||
// This is a regression test for CLD-591.
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 42.0.into_pixels());
|
||||
let mut tree = SumTree::new();
|
||||
tree.extend([
|
||||
// Height: 0-32, chars: 0-4
|
||||
laid_out_paragraph("1st\n", &TEST_STYLES, 40.0),
|
||||
// Height: 32-64, chars: 4-5
|
||||
laid_out_paragraph("\n", &TEST_STYLES, 40.0),
|
||||
// Height: 64-96, chars: 5-9
|
||||
laid_out_paragraph("2nd\n", &TEST_STYLES, 40.0),
|
||||
]);
|
||||
model.set_content(tree);
|
||||
|
||||
// A hit on the empty line should clamp to within that line, not the start of the next one.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
20.0.into_pixels(),
|
||||
40.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 4.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Up,
|
||||
block_start: CharOffset::from(4),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_on_soft_wrapped_line() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 60.0.into_pixels(), 30.0.into_pixels());
|
||||
model.set_content(SumTree::from_item(laid_out_paragraph(
|
||||
"Hello, world!\n",
|
||||
&TEST_STYLES,
|
||||
40., // This is less than the viewport width to account for the 20px of margin.
|
||||
)));
|
||||
|
||||
// A hit just after the end of a soft-wrapped line should wrap up.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
46.0.into_pixels(),
|
||||
6.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 4.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Up,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
// A hit at the start of the next line should have the same char offset, but wrap down.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
0.0.into_pixels(),
|
||||
15.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 4.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_after_end() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 50.0.into_pixels(), 60.0.into_pixels());
|
||||
model.set_content(SumTree::from_item(laid_out_paragraph(
|
||||
"ABCD\n",
|
||||
&TEST_STYLES,
|
||||
50.,
|
||||
)));
|
||||
|
||||
// A hit after the end, but on the same line, is like a soft-wrapped hit.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
45.0.into_pixels(),
|
||||
10.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 4.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Up,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
// A hit on the line after the last clamps to the end of content, but wraps
|
||||
// to the placeholder next line.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
20.0.into_pixels(),
|
||||
33.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 5.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 5.into(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_before_start() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
|
||||
model.set_content(SumTree::from_item(laid_out_paragraph(
|
||||
"ABCDEFGH\n",
|
||||
&TEST_STYLES,
|
||||
model.viewport().width().as_f32(),
|
||||
)));
|
||||
|
||||
// Hit before the start of the first soft-wrapped line, which should clamp to the first
|
||||
// character.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
(-4.).into_pixels(),
|
||||
10.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 0.into(),
|
||||
// Not considered clamped, because BlockItem::coordinates_to_location pre-clamps when handling
|
||||
// padding.
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 0.into(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
// Hit before the start of the second soft-wrapped line, which should clamp to the first
|
||||
// character of that line.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
(-4.).into_pixels(),
|
||||
20.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 4.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 0.into(),
|
||||
link: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_scrolled() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 42.0.into_pixels());
|
||||
let mut tree = SumTree::new();
|
||||
tree.extend([
|
||||
// Height: 0-32, chars: 0-3
|
||||
laid_out_paragraph("1st\n", &TEST_STYLES, 40.0),
|
||||
// Height: 32-64, chars: 4-7
|
||||
laid_out_paragraph("2nd\n", &TEST_STYLES, 40.0),
|
||||
// Height: 64-96, chars: 8-15
|
||||
laid_out_paragraph("wrapped\n", &TEST_STYLES, 40.0),
|
||||
// Height: 96-128, chars: 16-20
|
||||
laid_out_paragraph("last\n", &TEST_STYLES, 40.0),
|
||||
]);
|
||||
model.set_content(tree);
|
||||
|
||||
// Scroll the viewport directly since we don't have a ModelContext.
|
||||
model.viewport.scroll((-40.0).into_pixels(), model.height());
|
||||
|
||||
// Because of scrolling, this hits the second line.
|
||||
assert_eq!(
|
||||
model.viewport_coordinates_to_location(
|
||||
22.0.into_pixels(),
|
||||
0.2.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 6.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 4.into(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
// Now, scroll to the last viewport (containing the last paragraph, part of "ped", and the
|
||||
// trailing newline).
|
||||
model.viewport.scroll((-47.0).into_pixels(), model.height());
|
||||
|
||||
// This line is soft-wrapped to be partially in-viewport.
|
||||
assert_eq!(
|
||||
model.viewport_coordinates_to_location(
|
||||
36.0.into_pixels(),
|
||||
0.5.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 15.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Up,
|
||||
block_start: 8.into(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
// We should still be able to hit-test at the last line - accounting for the scroll position,
|
||||
// this is the very end of it.
|
||||
assert_eq!(
|
||||
model.viewport_coordinates_to_location(
|
||||
0.5.into_pixels(),
|
||||
20.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 16.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 16.into(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
// Hits past the last line should resolve to the last character in the buffer. In this case,
|
||||
// that's a newline, but in a real editor, it would be the last character of the last line,
|
||||
// with a TrailingNewline marker after it.
|
||||
assert_eq!(
|
||||
model.viewport_coordinates_to_location(
|
||||
2.0.into_pixels(),
|
||||
42.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 21.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 21.into(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_padding() {
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 40.0.into_pixels());
|
||||
let mut tree = SumTree::new();
|
||||
tree.extend([laid_out_paragraph("line\n", &TEST_STYLES, 40.0)]);
|
||||
model.set_content(tree);
|
||||
|
||||
// Hit in the padding after the paragraph ends. We should return the character that
|
||||
// matches the x-axis pixel position on the last line.
|
||||
assert_eq!(
|
||||
model.viewport_coordinates_to_location(
|
||||
38.0.into_pixels(),
|
||||
22.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 3.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: CharOffset::zero(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_code_block() {
|
||||
let width = 200.;
|
||||
let mut model =
|
||||
RenderState::new_for_test(TEST_STYLES.clone(), width.into_pixels(), 20.0.into_pixels());
|
||||
let mut tree = SumTree::new();
|
||||
tree.extend([
|
||||
laid_out_paragraph("Text\n", &TEST_STYLES, width),
|
||||
BlockItem::RunnableCodeBlock {
|
||||
paragraph_block: ParagraphBlock::new(layout_paragraphs(
|
||||
"Code 1\nCode 2",
|
||||
&TEST_STYLES,
|
||||
&BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Shell,
|
||||
},
|
||||
width - COMMAND_SPACING.x_axis_offset().as_f32(),
|
||||
)),
|
||||
code_block_type: Default::default(),
|
||||
},
|
||||
]);
|
||||
model.set_content(tree);
|
||||
|
||||
// Blocks by height:
|
||||
// * 0-32: First paragraph
|
||||
// * 32-56: Margin above code block
|
||||
// * 56-66: First line of code
|
||||
// * 66-76: Second line of code
|
||||
// * 76-92: Margin below code block
|
||||
// The code block is inset by 16px.
|
||||
|
||||
// Hits within the code block should have the right start location.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
30.0.into_pixels(),
|
||||
70.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
// The hit should be on the "o" on the second line of code.
|
||||
char_offset: 13.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
// The code block starts at offset 5.
|
||||
block_start: 5.into(),
|
||||
link: None,
|
||||
}
|
||||
);
|
||||
|
||||
// Hits within the code block's margin are treated as block selections.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
10.0.into_pixels(),
|
||||
50.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Block {
|
||||
start_offset: 5.into(),
|
||||
end_offset: 19.into(),
|
||||
block_type: HitTestBlockType::Code
|
||||
}
|
||||
);
|
||||
|
||||
// Hits in the horizontal padding are considered part of the text.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
8.0.into_pixels(),
|
||||
60.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 5.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 5.into(),
|
||||
link: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
90.0.into_pixels(),
|
||||
60.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 11.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Up,
|
||||
block_start: 5.into(),
|
||||
link: None
|
||||
}
|
||||
);
|
||||
|
||||
// The above rule holds even for out-of-bounds points.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
(-4.).into_pixels(),
|
||||
60.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 5.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 5.into(),
|
||||
link: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
1000.0.into_pixels(),
|
||||
60.0.into_pixels(),
|
||||
&Default::default()
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 11.into(),
|
||||
clamped: true,
|
||||
wrap_direction: WrapDirection::Up,
|
||||
block_start: 5.into(),
|
||||
link: None
|
||||
}
|
||||
);
|
||||
|
||||
// If block selection is disabled (e.g. due to dragging), we still clamp to text.
|
||||
assert_eq!(
|
||||
model.render_coordinates_to_location(
|
||||
27.0.into_pixels(),
|
||||
50.0.into_pixels(),
|
||||
&HitTestOptions {
|
||||
force_text_selection: true
|
||||
}
|
||||
),
|
||||
Location::Text {
|
||||
char_offset: 6.into(),
|
||||
clamped: false,
|
||||
wrap_direction: WrapDirection::Down,
|
||||
block_start: 5.into(),
|
||||
link: None
|
||||
}
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
use std::ops::{Add, Sub};
|
||||
|
||||
use itertools::Itertools;
|
||||
use num_traits::SaturatingSub;
|
||||
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use super::FrameOffset;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_map_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Mapping between visible character offsets and interactive content character offsets. Due to
|
||||
/// placeholder text, not all characters painted on the screen correspond to interactive characters
|
||||
/// in the content model.
|
||||
///
|
||||
/// The mapping model assumes that, within the overall set of characters in a text frame, only
|
||||
/// certain character runs are interactive. Within those runs, there's a 1:1 mapping between
|
||||
/// `char`s in the content model and `char`s in the text frame. It translates in two directions:
|
||||
/// * From a [`CharOffset`] relative to the start of the content model block (a [`super::Paragraph`])
|
||||
/// to the character index in the [`warpui::text_layout::TextFrame`].
|
||||
/// * From a [`FrameOffset`] in the `TextFrame` to the closest `CharOffset` in the content model
|
||||
/// block (for example, clicking within a placeholder should snap the cursor to a regular content
|
||||
/// character).
|
||||
///
|
||||
/// An interactive character is one that the user can select and edit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OffsetMap {
|
||||
/// Sorted list of interactive content runs.
|
||||
runs: Vec<SelectableTextRun>,
|
||||
}
|
||||
|
||||
/// A run of selectable/interactive content in the [`OffsetMap`]. This includes all user-written
|
||||
/// characters, but not placeholder text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SelectableTextRun {
|
||||
/// The offset of the first content-character in this run.
|
||||
pub content_start: CharOffset,
|
||||
/// The offset of the first [`TextFrame`] visible character in this run.
|
||||
pub frame_start: FrameOffset,
|
||||
/// The total number of characters in this run. The run length is the same for both content
|
||||
/// characters and text frame characters. That is, this run represents:
|
||||
/// * Characters `content_start..content_start+length` within the containing paragraph
|
||||
/// * Characters `frame_start..frame_start+length` within the containing `TextFrame`
|
||||
pub length: usize,
|
||||
}
|
||||
|
||||
impl OffsetMap {
|
||||
/// Creates a new [`OffsetMap`] that translates 1:1 between visible character offsets and
|
||||
/// interactive content character offsets.
|
||||
pub fn direct(length: usize) -> Self {
|
||||
Self::new(vec![SelectableTextRun {
|
||||
content_start: CharOffset::zero(),
|
||||
frame_start: FrameOffset::zero(),
|
||||
length,
|
||||
}])
|
||||
}
|
||||
|
||||
pub fn new(mut runs: Vec<SelectableTextRun>) -> Self {
|
||||
runs.sort_unstable_by_key(|r| r.frame_start);
|
||||
if cfg!(debug_assertions) {
|
||||
// Content- and frame-offsets should both increase from run to run.
|
||||
for (a, b) in runs.iter().tuple_windows() {
|
||||
assert!(a.content_start < b.content_start, "Runs must be ascending");
|
||||
}
|
||||
}
|
||||
Self { runs }
|
||||
}
|
||||
|
||||
/// Translate a visible character to the closest content character.
|
||||
pub fn to_content(&self, offset: FrameOffset) -> CharOffset {
|
||||
self.translate(offset)
|
||||
}
|
||||
|
||||
/// Translate a content character to the corresponding visible character.
|
||||
pub fn to_frame(&self, offset: CharOffset) -> FrameOffset {
|
||||
// When going from content to frame offsets, the character is almost always going to be
|
||||
// inside an interactive run. The exception is the placeholder marker character, and so
|
||||
// it's simpler to reuse the translation algorithm from the other direction rather than
|
||||
// detect and handle this special case.
|
||||
self.translate(offset)
|
||||
}
|
||||
|
||||
/// Translate from one kind of offset to the other. If the source offset is within an interactive
|
||||
/// run, this returns the exact location of the character within that run. Otherwise, it rounds
|
||||
/// up or down to the nearest interactive run.
|
||||
fn translate<T: ParagraphOffset, U: ParagraphOffset>(&self, offset: T) -> U {
|
||||
// Find the runs before and after `offset`, and calculate for each:
|
||||
// - The absolute distance from it to `offset`
|
||||
// - The translated offset according to it
|
||||
let after_idx = self.runs.partition_point(|run| T::start(run) < offset);
|
||||
|
||||
let after = self.runs.get(after_idx).map(|run| {
|
||||
let distance = T::start(run) - offset;
|
||||
// If the run after `offset` is closer, then snap to the start of that run (e.g. the
|
||||
// first regular character after a placeholder).
|
||||
(distance, U::start(run))
|
||||
});
|
||||
|
||||
let before = after_idx
|
||||
.checked_sub(1)
|
||||
.and_then(|idx| self.runs.get(idx))
|
||||
.map(|run| {
|
||||
// For the before run, we measure the distance from its end to offset. However, the
|
||||
// offset could be within the run, so we clamp down to 0.
|
||||
let run_end = T::start(run) + run.length;
|
||||
let distance = offset.saturating_sub(&run_end);
|
||||
// If we pick the before run, we essentially rebase it onto the destination start, but then
|
||||
// clamp to stay within the run.
|
||||
let translation =
|
||||
U::start(run) + (offset - T::start(run)).as_usize().min(run.length);
|
||||
(distance, translation)
|
||||
});
|
||||
|
||||
match (before, after) {
|
||||
(None, None) => U::zero(), // This should only happen if the frame is empty.
|
||||
(None, Some((_, translation))) => translation,
|
||||
(Some((_, translation)), None) => translation,
|
||||
(
|
||||
Some((before_distance, before_translation)),
|
||||
Some((after_distance, after_translation)),
|
||||
) => {
|
||||
if before_distance < after_distance {
|
||||
before_translation
|
||||
} else {
|
||||
after_translation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstracts over [`FrameOffset`] and [`CharOffset`] so that we can use the same conversion
|
||||
/// algorithm for both.
|
||||
trait ParagraphOffset:
|
||||
Add<usize, Output = Self> + Ord + Sub<Output = Self> + SaturatingSub + Copy + Sized
|
||||
{
|
||||
/// Gets the start offset of a [`SelectableTextRun`] for this offset/coordinate system.
|
||||
fn start(run: &SelectableTextRun) -> Self;
|
||||
|
||||
fn zero() -> Self;
|
||||
|
||||
fn as_usize(self) -> usize;
|
||||
}
|
||||
|
||||
impl ParagraphOffset for CharOffset {
|
||||
fn start(run: &SelectableTextRun) -> Self {
|
||||
run.content_start
|
||||
}
|
||||
|
||||
fn zero() -> Self {
|
||||
CharOffset::zero()
|
||||
}
|
||||
|
||||
fn as_usize(self) -> usize {
|
||||
CharOffset::as_usize(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ParagraphOffset for FrameOffset {
|
||||
fn start(run: &SelectableTextRun) -> Self {
|
||||
run.frame_start
|
||||
}
|
||||
|
||||
fn zero() -> Self {
|
||||
FrameOffset::zero()
|
||||
}
|
||||
|
||||
fn as_usize(self) -> usize {
|
||||
FrameOffset::as_usize(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::{OffsetMap, SelectableTextRun};
|
||||
use crate::render::model::FrameOffset;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
#[test]
|
||||
fn test_offset_map_basic() {
|
||||
// Baseline test for a no-placeholder OffsetMap. The content_start is non-zero to mimic
|
||||
// paragraphs within a code block.
|
||||
let map = OffsetMap::new(vec![SelectableTextRun {
|
||||
content_start: CharOffset::from(12),
|
||||
frame_start: FrameOffset::zero(),
|
||||
length: 10,
|
||||
}]);
|
||||
|
||||
// The returned offset should be adjusted by the content start.
|
||||
assert_eq!(map.to_content(FrameOffset::from(4)), 16.into());
|
||||
// Mapping should clamp to run bounds.
|
||||
assert_eq!(map.to_content(FrameOffset::from(12)), 22.into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offset_map_placeholders() {
|
||||
// Set up an offset map for the following structure:
|
||||
// |placeholder|text|placeholder|text|placeholder...
|
||||
// Frame: 0 6 14 24 28
|
||||
// Content: 0 1 9 10 14
|
||||
let map = OffsetMap::new(vec![
|
||||
SelectableTextRun {
|
||||
// Even in the zero-state placeholder case, there's an empty content run just before it.
|
||||
content_start: CharOffset::zero(),
|
||||
frame_start: FrameOffset::zero(),
|
||||
length: 0,
|
||||
},
|
||||
SelectableTextRun {
|
||||
content_start: CharOffset::from(1),
|
||||
frame_start: FrameOffset::from(6),
|
||||
length: 8,
|
||||
},
|
||||
SelectableTextRun {
|
||||
content_start: CharOffset::from(10),
|
||||
frame_start: FrameOffset::from(24),
|
||||
length: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
// Depending on what they're closer to, characters at the start of the frame map to either
|
||||
// the start of the line or the first content run.
|
||||
assert_eq!(map.to_content(FrameOffset::from(2)), CharOffset::zero());
|
||||
assert_eq!(map.to_content(FrameOffset::from(4)), CharOffset::from(1));
|
||||
assert_eq!(map.to_frame(CharOffset::zero()), FrameOffset::zero());
|
||||
assert_eq!(map.to_frame(CharOffset::from(1)), FrameOffset::from(6));
|
||||
|
||||
// Characters within the first text range map within the range.
|
||||
assert_eq!(map.to_content(FrameOffset::from(7)), CharOffset::from(2));
|
||||
assert_eq!(map.to_frame(CharOffset::from(2)), FrameOffset::from(7));
|
||||
|
||||
// Characters within the second placeholder map to the closer run.
|
||||
assert_eq!(map.to_content(FrameOffset::from(16)), CharOffset::from(9));
|
||||
assert_eq!(map.to_content(FrameOffset::from(20)), CharOffset::from(10));
|
||||
assert_eq!(map.to_frame(CharOffset::from(9)), FrameOffset::from(14));
|
||||
assert_eq!(map.to_frame(CharOffset::from(10)), FrameOffset::from(24));
|
||||
|
||||
// Characters in the last placeholder map to the end of the last text run.
|
||||
assert_eq!(map.to_content(FrameOffset::from(28)), CharOffset::from(14));
|
||||
assert_eq!(map.to_content(FrameOffset::from(50)), CharOffset::from(14));
|
||||
assert_eq!(map.to_frame(CharOffset::from(14)), FrameOffset::from(28));
|
||||
}
|
||||
|
||||
/// Walkthrough test to demonstrate how placeholders are represented in the [`OffsetMap`] and
|
||||
/// [`TextFrame`].
|
||||
///
|
||||
/// This test only runs on macOS because it needs a text-layout implementation for [`EditDelta`]
|
||||
/// that creates non-empty text frames.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn test_end_to_end() {
|
||||
// Group imports here so they don't cause "unused import" warnings on other targets.
|
||||
|
||||
use warpui::{
|
||||
App, color::ColorU, elements::Fill, fonts::Cache as FontCache, text_layout::LayoutCache,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
content::{
|
||||
buffer::{Buffer, BufferEditAction, EditOrigin},
|
||||
selection_model::BufferSelectionModel,
|
||||
text::IndentBehavior,
|
||||
},
|
||||
render::{
|
||||
layout::TextLayout,
|
||||
model::{
|
||||
BlockItem, BrokenLinkStyle, CheckBoxStyle, HorizontalRuleStyle, InlineCodeStyle,
|
||||
PARAGRAPH_MIN_HEIGHT, ParagraphStyles, RenderLayoutOptions, RichTextStyles,
|
||||
TableStyle, test_utils::TEST_BASELINE_OFFSET,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let mut font_cache = FontCache::new(Box::new(warpui::platform::current::FontDB::new()));
|
||||
let layout_cache = LayoutCache::new();
|
||||
let paragraph_styles = ParagraphStyles {
|
||||
font_family: font_cache
|
||||
.load_system_font("Arial")
|
||||
.expect("Arial must exist"),
|
||||
font_size: 12.,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: 1.2,
|
||||
text_color: ColorU::white(),
|
||||
baseline_ratio: TEST_BASELINE_OFFSET,
|
||||
fixed_width_tab_size: None,
|
||||
};
|
||||
let inline_code = InlineCodeStyle {
|
||||
font_family: font_cache
|
||||
.load_system_font("Arial")
|
||||
.expect("Arial must exist"),
|
||||
background: ColorU::black(),
|
||||
font_color: ColorU::white(),
|
||||
};
|
||||
let checkbox = CheckBoxStyle {
|
||||
border_color: ColorU::white(),
|
||||
border_width: 2.,
|
||||
icon_path: "bundled/svg/check-thick.svg",
|
||||
background: ColorU::black(),
|
||||
hover_background: ColorU::black(),
|
||||
};
|
||||
let horizontal_rule = HorizontalRuleStyle {
|
||||
rule_height: 2.,
|
||||
color: ColorU::black(),
|
||||
};
|
||||
let broken_link = BrokenLinkStyle {
|
||||
icon_path: "bundled/svg/link-broken-02.svg",
|
||||
icon_color: ColorU::black(),
|
||||
};
|
||||
let styles = RichTextStyles {
|
||||
base_text: paragraph_styles,
|
||||
code_text: paragraph_styles,
|
||||
embedding_text: paragraph_styles,
|
||||
code_background: Default::default(),
|
||||
embedding_background: Default::default(),
|
||||
placeholder_color: ColorU::black(),
|
||||
code_border: Default::default(),
|
||||
selection_fill: Fill::None,
|
||||
cursor_fill: Fill::None,
|
||||
inline_code_style: inline_code,
|
||||
check_box_style: checkbox,
|
||||
horizontal_rule_style: horizontal_rule,
|
||||
broken_link_style: broken_link,
|
||||
block_spacings: Default::default(),
|
||||
show_placeholder_text_on_empty_block: false,
|
||||
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
|
||||
cursor_width: 1.,
|
||||
highlight_urls: true,
|
||||
table_style: TableStyle {
|
||||
border_color: ColorU::black(),
|
||||
header_background: ColorU::black(),
|
||||
cell_background: ColorU::black(),
|
||||
alternate_row_background: None,
|
||||
text_color: ColorU::white(),
|
||||
header_text_color: ColorU::white(),
|
||||
scrollbar_nonactive_thumb_color: ColorU::white(),
|
||||
scrollbar_active_thumb_color: ColorU::white(),
|
||||
font_family: paragraph_styles.font_family,
|
||||
font_size: 12.,
|
||||
cell_padding: 8.0,
|
||||
outer_border: true,
|
||||
column_dividers: true,
|
||||
row_dividers: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Start by creating a buffer with a single line of text that includes a placeholder.
|
||||
let buffer_handle = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
|
||||
let selection_handle = app.add_model(|_| BufferSelectionModel::new(buffer_handle.clone()));
|
||||
|
||||
buffer_handle.update(&mut app, |buffer, ctx| {
|
||||
buffer.update_content(
|
||||
BufferEditAction::Insert {
|
||||
text: "HelloWorld",
|
||||
style: Default::default(),
|
||||
override_text_style: None,
|
||||
},
|
||||
EditOrigin::UserInitiated,
|
||||
selection_handle.clone(),
|
||||
ctx,
|
||||
);
|
||||
buffer.update_content(
|
||||
BufferEditAction::InsertPlaceholder {
|
||||
text: "test",
|
||||
location: CharOffset::from(6),
|
||||
},
|
||||
EditOrigin::SystemEdit,
|
||||
selection_handle.clone(),
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
buffer.debug(),
|
||||
"<text>Hello<placeholder_s>test<placeholder_e>World"
|
||||
);
|
||||
// The placeholder only counts as 1 character, so there are 11 buffer characters.
|
||||
assert_eq!(buffer.max_charoffset(), 12.into());
|
||||
});
|
||||
|
||||
// Now, lay out the buffer, which should produce a single `Paragraph` block.
|
||||
let layout = app.read(|ctx| {
|
||||
let delta = buffer_handle.as_ref(ctx).invalidate_layout();
|
||||
let text_layout = TextLayout::new(
|
||||
&layout_cache,
|
||||
font_cache.text_layout_system(),
|
||||
&styles,
|
||||
1000.,
|
||||
);
|
||||
delta.layout_delta(
|
||||
&text_layout,
|
||||
None,
|
||||
RenderLayoutOptions::default(),
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let paragraph = match &layout.laid_out_line[..] {
|
||||
[BlockItem::Paragraph(paragraph)] => paragraph,
|
||||
other => panic!("Unexpected blocks: {other:?}"),
|
||||
};
|
||||
|
||||
// The `TextFrame` includes each character we paint: "HellotestWorld".
|
||||
let line = ¶graph.frame.lines()[0];
|
||||
assert_eq!(
|
||||
line.runs.iter().map(|run| run.glyphs.len()).sum::<usize>(),
|
||||
14
|
||||
);
|
||||
assert_eq!(line.first_index(), 0); // The "H" glyph.
|
||||
assert_eq!(line.last_index(), 13); // The "d" glyph.
|
||||
|
||||
// Because "test" is a placeholder, it creates a gap in the `OffsetMap`:
|
||||
// - Characters 0-4 in the buffer map to characters 0-5 in the text frame ("Hello")
|
||||
// - The character at buffer index 5 is the placeholder ("test"). It's not in the map, but
|
||||
// is painted by characters 5-8 in the TextFrame
|
||||
// - Characters 6-10 in the buffer map to characters 9-13 in the text frame ("World").
|
||||
// Overall, it looks like this:
|
||||
// Character: H e l l o | t e s t | W o r l d
|
||||
// Buffer Index: 0 1 2 3 4 | 5 | 6 7 8 9 10
|
||||
// TextFrame Index: 0 1 2 3 4 | 5 6 7 8 | 9 10 11 12 13
|
||||
|
||||
// In the OffsetMap representation, we only store the runs of non-placeholder characters,
|
||||
// while placeholder characters form un-selectable "holes".
|
||||
assert_eq!(
|
||||
paragraph.offsets.runs,
|
||||
vec![
|
||||
// The run for "Hello":
|
||||
SelectableTextRun {
|
||||
content_start: 0.into(),
|
||||
frame_start: 0.into(),
|
||||
length: 5
|
||||
},
|
||||
// The run for "World":
|
||||
SelectableTextRun {
|
||||
content_start: 6.into(),
|
||||
frame_start: 9.into(),
|
||||
length: 5
|
||||
}
|
||||
]
|
||||
);
|
||||
// To go from a buffer character to a TextFrame character, we find the run that contains
|
||||
// it - for offset `i`, this is the run where `run.content_start <= i < run.content_start + run.length`.
|
||||
// Going from a TextFrame character to a buffer character is more complicated, because the
|
||||
// character might belong to a placeholder. In that case, we find the two adjacent runs and
|
||||
// pick the closest.
|
||||
// Some examples:
|
||||
|
||||
// The "e" in "Hello":
|
||||
assert_eq!(paragraph.offsets.to_frame(1.into()), 1.into());
|
||||
assert_eq!(paragraph.offsets.to_content(1.into()), 1.into());
|
||||
|
||||
// The "s" in "test":
|
||||
// Since it's in a placeholder, we can only use the placeholder's buffer char offset.
|
||||
assert_eq!(paragraph.offsets.to_frame(5.into()), 5.into());
|
||||
// When going the other direction, it's closer to World than Hello.
|
||||
assert_eq!(paragraph.offsets.to_content(7.into()), 6.into());
|
||||
|
||||
// The "r" in "World": Note that the offsets don't map 1:1 because we have to account for
|
||||
// the placeholder gap.
|
||||
assert_eq!(paragraph.offsets.to_frame(8.into()), 11.into());
|
||||
assert_eq!(paragraph.offsets.to_content(11.into()), 8.into());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Utilities for traversing laid-out blocks along with their positioning
|
||||
//! information.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use sum_tree::{Cursor, Dimension};
|
||||
use warpui::{
|
||||
geometry::vector::Vector2F,
|
||||
text_layout::Line,
|
||||
units::{IntoPixels, Pixels},
|
||||
};
|
||||
|
||||
use crate::render::layout::line_height;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use super::{
|
||||
BlockItem, BlockSpacing, HorizontalRuleConfig, ImageBlockConfig, LaidOutEmbeddedItem,
|
||||
LaidOutTable, LayoutSummary, LineCount, Paragraph, ParagraphBlock, RenderContext, bounds,
|
||||
};
|
||||
|
||||
/// Wrapper to track an item's position, both in the buffer and on the screen.
|
||||
#[derive(Debug)]
|
||||
pub struct Positioned<'a, T> {
|
||||
/// The starting character offset of this item, relative to the start of
|
||||
/// the buffer.
|
||||
pub start_char_offset: CharOffset,
|
||||
/// The starting line number of this item.
|
||||
pub start_line: LineCount,
|
||||
/// The starting y-offset of this item, relative to the origin of the laid-out
|
||||
/// content.
|
||||
// TODO: There are at least 4 valid origins for pixel coordinates (content start,
|
||||
// viewport start, paint origin, and block start). If that starts getting
|
||||
// confusing, we might want to introduce wrappers similar to `DisplayPoint`
|
||||
// and `SoftWrapPoint` in the input editor or `WithinBlock` and `WithinModel`
|
||||
// in the terminal.
|
||||
pub start_y_offset: Pixels,
|
||||
pub style: BlockSpacing,
|
||||
pub item: &'a T,
|
||||
}
|
||||
|
||||
pub trait PositionedCursor<'a> {
|
||||
/// The block at the current cursor position, along with its position.
|
||||
fn positioned_item(&self) -> Option<Positioned<'a, BlockItem>>;
|
||||
}
|
||||
|
||||
impl<'a, S: Dimension<'a, LayoutSummary>> PositionedCursor<'a>
|
||||
for Cursor<'a, BlockItem, S, LayoutSummary>
|
||||
{
|
||||
fn positioned_item(&self) -> Option<Positioned<'a, BlockItem>> {
|
||||
let item = self.item()?;
|
||||
let summary = self.start();
|
||||
|
||||
Some(Positioned {
|
||||
start_char_offset: summary.content_length,
|
||||
start_y_offset: (summary.height as f32).into_pixels(),
|
||||
start_line: summary.lines,
|
||||
style: item.spacing(),
|
||||
item,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Positioned<'_, T> {
|
||||
/// The origin of this item's content, relative to the start of the buffer.
|
||||
pub fn content_origin(&self) -> Vector2F {
|
||||
bounds::content_origin(self.start_y_offset, &self.style)
|
||||
}
|
||||
|
||||
/// The visible origin of this item, relative to the start of the buffer.
|
||||
pub fn visible_origin(&self) -> Vector2F {
|
||||
bounds::visible_origin(self.start_y_offset, &self.style)
|
||||
}
|
||||
|
||||
/// The origin of this item, relative to the start of the buffer, with no padding
|
||||
/// or margin.
|
||||
pub fn reserved_origin(&self) -> Vector2F {
|
||||
bounds::reserved_origin(self.start_y_offset)
|
||||
}
|
||||
|
||||
/// The origin of this item in rendering coordinates.
|
||||
pub fn render_origin(&self, ctx: &RenderContext) -> Vector2F {
|
||||
ctx.content_to_screen(self.reserved_origin())
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers specific to positioned [`Line`]s.
|
||||
impl Positioned<'_, Line> {
|
||||
pub fn end_y_offset(&self) -> Pixels {
|
||||
self.start_y_offset + line_height(self.item).into_pixels() + self.style.top_offset()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers specific to a positioned [`BlockItem`].
|
||||
impl<'a> Positioned<'a, BlockItem> {
|
||||
/// The ending character offset of this item (exclusive).
|
||||
pub fn end_char_offset(&self) -> CharOffset {
|
||||
self.start_char_offset + self.item.content_length()
|
||||
}
|
||||
|
||||
/// Check if this block item contains a content offset.
|
||||
pub fn contains_content(&self, offset: CharOffset) -> bool {
|
||||
self.start_char_offset <= offset && self.end_char_offset() > offset
|
||||
}
|
||||
|
||||
/// The ending line number of this item (exclusive).
|
||||
pub fn end_line(&self) -> LineCount {
|
||||
self.start_line + self.item.lines()
|
||||
}
|
||||
|
||||
/// Position this item's code block.
|
||||
pub fn code_block(&self, block: &'a ParagraphBlock) -> Positioned<'a, ParagraphBlock> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::RunnableCodeBlock { .. }),
|
||||
"Must be a runnable code block"
|
||||
);
|
||||
self.position(block)
|
||||
}
|
||||
|
||||
pub fn temporary_block(&self, block: &'a ParagraphBlock) -> Positioned<'a, ParagraphBlock> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::TemporaryBlock { .. }),
|
||||
"Must be a temporary block"
|
||||
);
|
||||
self.position(block)
|
||||
}
|
||||
|
||||
pub fn embedded(
|
||||
&self,
|
||||
embedded_item: &'a Arc<dyn LaidOutEmbeddedItem>,
|
||||
) -> Positioned<'a, Arc<dyn LaidOutEmbeddedItem>> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::Embedded(_)),
|
||||
"Must be an embedded object"
|
||||
);
|
||||
self.position(embedded_item)
|
||||
}
|
||||
|
||||
pub fn task_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::TaskList { .. }),
|
||||
"Must be a task list block"
|
||||
);
|
||||
self.position(paragraph)
|
||||
}
|
||||
|
||||
pub fn unordered_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::UnorderedList { .. }),
|
||||
"Must be an unordered list block"
|
||||
);
|
||||
self.position(paragraph)
|
||||
}
|
||||
|
||||
/// Position the content paragraph for an ordered list item.
|
||||
pub fn ordered_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::OrderedList { .. }),
|
||||
"Must be an ordered list block"
|
||||
);
|
||||
self.position(paragraph)
|
||||
}
|
||||
|
||||
pub fn header(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::Header { .. }),
|
||||
"Must be a header block"
|
||||
);
|
||||
self.position_centered(paragraph, paragraph.height())
|
||||
}
|
||||
|
||||
/// Position this item's paragraph.
|
||||
pub fn paragraph(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::Paragraph(_)),
|
||||
"Must be a paragraph"
|
||||
);
|
||||
// Short paragraphs may have extra padding to meet the minimum paragraph height.
|
||||
self.position_centered(paragraph, paragraph.height())
|
||||
}
|
||||
|
||||
pub fn text_block(
|
||||
&self,
|
||||
paragraph_block: &'a ParagraphBlock,
|
||||
) -> Positioned<'a, ParagraphBlock> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::TextBlock { .. }),
|
||||
"Must be a text block"
|
||||
);
|
||||
self.position(paragraph_block)
|
||||
}
|
||||
|
||||
/// Position the trailing newline cursor.
|
||||
pub fn trailing_newline(&self, cursor: &'a super::Cursor) -> Positioned<'a, super::Cursor> {
|
||||
// Match the spacing behavior of single-line paragraphs.
|
||||
self.position_centered(cursor, cursor.height)
|
||||
}
|
||||
|
||||
pub fn horizontal_rule(
|
||||
&self,
|
||||
horizontal_rule: &'a HorizontalRuleConfig,
|
||||
) -> Positioned<'a, HorizontalRuleConfig> {
|
||||
self.position_centered(horizontal_rule, horizontal_rule.line_height)
|
||||
}
|
||||
|
||||
pub fn image(&self, image_config: &'a ImageBlockConfig) -> Positioned<'a, ImageBlockConfig> {
|
||||
self.position_centered(image_config, image_config.height)
|
||||
}
|
||||
|
||||
pub fn table(&self, laid_out_table: &'a LaidOutTable) -> Positioned<'a, LaidOutTable> {
|
||||
debug_assert!(
|
||||
matches!(self.item, BlockItem::Table(_)),
|
||||
"Must be a table block"
|
||||
);
|
||||
self.position_centered(laid_out_table, laid_out_table.height())
|
||||
}
|
||||
|
||||
/// Helper to position-wrap an item's contents when pattern-matching.
|
||||
///
|
||||
/// ```ignore
|
||||
/// match positioned.item {
|
||||
/// BlockItem::Paragraph(paragraph) => positioned.position(paragraph),
|
||||
/// }
|
||||
/// ```
|
||||
fn position<T>(&self, content: &'a T) -> Positioned<'a, T> {
|
||||
Positioned {
|
||||
start_char_offset: self.start_char_offset,
|
||||
start_y_offset: self.start_y_offset,
|
||||
start_line: self.start_line,
|
||||
style: self.style,
|
||||
item: content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `positioned`, but centers the content based on the block's expected content height.
|
||||
/// Use this for blocks with a minimum height.
|
||||
fn position_centered<T>(
|
||||
&self,
|
||||
content: &'a T,
|
||||
actual_content_height: Pixels,
|
||||
) -> Positioned<'a, T> {
|
||||
let mut positioned = self.position(content);
|
||||
let gap = self.item.content_height() - actual_content_height;
|
||||
positioned.start_y_offset += gap / 2.0.into_pixels();
|
||||
positioned
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use warpui::EntityId;
|
||||
|
||||
/// Utility for consistently creating and referencing saved position IDs for
|
||||
/// rich text.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SavedPositions {
|
||||
/// Entity ID for the [`super::RenderState`] that owns these positions. This
|
||||
/// disambiguates IDs across multiple rich text editors.
|
||||
model_id: EntityId,
|
||||
}
|
||||
|
||||
impl SavedPositions {
|
||||
/// Create a new `SavedPositions` given the parent model ID.
|
||||
pub(super) fn new(model_id: EntityId) -> Self {
|
||||
Self { model_id }
|
||||
}
|
||||
|
||||
/// Saved position ID for the cursor location.
|
||||
pub fn cursor_id(&self) -> String {
|
||||
format!("warp_editor:cursor_{}", self.model_id)
|
||||
}
|
||||
|
||||
/// The bounding box for the text selection.
|
||||
pub fn text_selection_id(&self) -> String {
|
||||
format!("warp_editor:text_selection_{}", self.model_id)
|
||||
}
|
||||
|
||||
/// The first line of the block that the mouse is currently hovered over.
|
||||
pub fn hovered_block_start(&self) -> String {
|
||||
format!("warp_editor:hovered_block_start_{}", self.model_id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
use markdown_parser::FormattedTextFragment;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
/// Maps between linear CharOffset positions and table cell coordinates.
|
||||
///
|
||||
/// The table content is represented as:
|
||||
/// ```text
|
||||
/// Header1\tHeader2\tHeader3\n
|
||||
/// Cell1\tCell2\tCell3\n
|
||||
/// Cell4\tCell5\tCell6\n
|
||||
/// ```
|
||||
///
|
||||
/// This structure enables:
|
||||
/// - Finding which cell contains a given CharOffset
|
||||
/// - Getting the CharOffset range for a specific cell
|
||||
/// - Determining if an offset is on a separator (tab or newline)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TableOffsetMap {
|
||||
cell_ranges: Vec<CellRange>,
|
||||
row_ranges: Vec<RowRange>,
|
||||
cell_index_by_row_col: Vec<Vec<usize>>,
|
||||
total_length: CharOffset,
|
||||
num_rows: usize,
|
||||
num_cols: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RowRange {
|
||||
start: CharOffset,
|
||||
end: CharOffset,
|
||||
}
|
||||
|
||||
/// A range representing a single cell's position in the linear character stream.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CellRange {
|
||||
pub start: CharOffset,
|
||||
pub end: CharOffset,
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
/// The location of a character offset within a table cell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CellAtOffset {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub offset_in_cell: CharOffset,
|
||||
}
|
||||
|
||||
/// The character offset range (start, end) of a cell in the linear content stream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CellOffsetRange {
|
||||
pub start: CharOffset,
|
||||
pub end: CharOffset,
|
||||
}
|
||||
|
||||
/// Result of looking up a CharOffset in the table.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TablePosition {
|
||||
/// Offset is within a cell's text content
|
||||
InCell {
|
||||
row: usize,
|
||||
col: usize,
|
||||
offset_in_cell: CharOffset,
|
||||
},
|
||||
/// Offset is on a tab separator between cells
|
||||
OnTab { row: usize, after_col: usize },
|
||||
/// Offset is on a newline at the end of a row
|
||||
OnNewline { row: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TableCellOffsetMap {
|
||||
fragment_ranges: Vec<TableCellFragmentRange>,
|
||||
rendered_length: CharOffset,
|
||||
source_length: CharOffset,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TableCellFragmentRange {
|
||||
rendered_start: CharOffset,
|
||||
rendered_end: CharOffset,
|
||||
source_end: CharOffset,
|
||||
visible_source_start: CharOffset,
|
||||
visible_source_end: CharOffset,
|
||||
}
|
||||
impl TableOffsetMap {
|
||||
/// Build a new TableOffsetMap from cell text lengths.
|
||||
///
|
||||
/// `cell_lengths` is a 2D array where cell_lengths[row][col] is the character
|
||||
/// count of that cell's text content.
|
||||
pub fn new(cell_lengths: Vec<Vec<usize>>) -> Self {
|
||||
let num_rows = cell_lengths.len();
|
||||
let num_cols = cell_lengths.first().map(|r| r.len()).unwrap_or(0);
|
||||
|
||||
let mut cell_ranges = Vec::new();
|
||||
let mut row_ranges = Vec::with_capacity(num_rows);
|
||||
let mut cell_index_by_row_col = Vec::with_capacity(num_rows);
|
||||
let mut current_offset = CharOffset::zero();
|
||||
|
||||
for (row_idx, row) in cell_lengths.iter().enumerate() {
|
||||
let row_start = current_offset;
|
||||
let mut row_cell_indices = Vec::with_capacity(row.len());
|
||||
|
||||
for (col_idx, &cell_len) in row.iter().enumerate() {
|
||||
let start = current_offset;
|
||||
let end = start + cell_len;
|
||||
let cell_idx = cell_ranges.len();
|
||||
|
||||
cell_ranges.push(CellRange {
|
||||
start,
|
||||
end,
|
||||
row: row_idx,
|
||||
col: col_idx,
|
||||
});
|
||||
row_cell_indices.push(cell_idx);
|
||||
current_offset = end;
|
||||
|
||||
if col_idx < row.len() - 1 {
|
||||
current_offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
current_offset += 1;
|
||||
row_ranges.push(RowRange {
|
||||
start: row_start,
|
||||
end: current_offset,
|
||||
});
|
||||
cell_index_by_row_col.push(row_cell_indices);
|
||||
}
|
||||
|
||||
Self {
|
||||
cell_ranges,
|
||||
row_ranges,
|
||||
cell_index_by_row_col,
|
||||
total_length: current_offset,
|
||||
num_rows,
|
||||
num_cols,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the total content length including all cells, tabs, and newlines.
|
||||
pub fn total_length(&self) -> CharOffset {
|
||||
self.total_length
|
||||
}
|
||||
|
||||
/// Find what's at the given offset.
|
||||
pub fn position_at_offset(&self, offset: CharOffset) -> Option<TablePosition> {
|
||||
if offset >= self.total_length {
|
||||
return None;
|
||||
}
|
||||
|
||||
let row_idx = self
|
||||
.row_ranges
|
||||
.partition_point(|row_range| row_range.end <= offset);
|
||||
let row_range = self.row_ranges.get(row_idx)?;
|
||||
if offset < row_range.start {
|
||||
return None;
|
||||
}
|
||||
|
||||
let row_cells = self.cell_index_by_row_col.get(row_idx)?;
|
||||
let mut previous_cell: Option<CellRange> = None;
|
||||
for &cell_idx in row_cells {
|
||||
let cell = *self.cell_ranges.get(cell_idx)?;
|
||||
|
||||
if offset < cell.start {
|
||||
return previous_cell.map(|cell| self.separator_position(cell));
|
||||
}
|
||||
|
||||
if offset < cell.end {
|
||||
return Some(TablePosition::InCell {
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
offset_in_cell: offset - cell.start,
|
||||
});
|
||||
}
|
||||
|
||||
if offset == cell.end {
|
||||
return Some(self.separator_position(cell));
|
||||
}
|
||||
|
||||
previous_cell = Some(cell);
|
||||
}
|
||||
|
||||
Some(TablePosition::OnNewline { row: row_idx })
|
||||
}
|
||||
|
||||
/// Find which cell contains the given offset.
|
||||
/// If the offset is on a separator, returns the cell before the separator.
|
||||
pub fn cell_at_offset(&self, offset: CharOffset) -> Option<CellAtOffset> {
|
||||
match self.position_at_offset(offset)? {
|
||||
TablePosition::InCell {
|
||||
row,
|
||||
col,
|
||||
offset_in_cell,
|
||||
} => Some(CellAtOffset {
|
||||
row,
|
||||
col,
|
||||
offset_in_cell,
|
||||
}),
|
||||
TablePosition::OnTab { row, after_col } => {
|
||||
let cell = self.cell_range(row, after_col)?;
|
||||
Some(CellAtOffset {
|
||||
row,
|
||||
col: after_col,
|
||||
offset_in_cell: cell.end - cell.start,
|
||||
})
|
||||
}
|
||||
TablePosition::OnNewline { row } => {
|
||||
let last_col = self
|
||||
.cell_index_by_row_col
|
||||
.get(row)
|
||||
.map(|cells| cells.len())
|
||||
.unwrap_or(0)
|
||||
.saturating_sub(1);
|
||||
let cell = self.cell_range(row, last_col)?;
|
||||
Some(CellAtOffset {
|
||||
row,
|
||||
col: last_col,
|
||||
offset_in_cell: cell.end - cell.start,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the character offset range for a specific cell by (row, col).
|
||||
pub fn cell_range(&self, row: usize, col: usize) -> Option<CellOffsetRange> {
|
||||
let cell_idx = *self.cell_index_by_row_col.get(row)?.get(col)?;
|
||||
let cell = self.cell_ranges.get(cell_idx)?;
|
||||
Some(CellOffsetRange {
|
||||
start: cell.start,
|
||||
end: cell.end,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if an offset is on a tab or newline separator.
|
||||
pub fn is_separator(&self, offset: CharOffset) -> bool {
|
||||
matches!(
|
||||
self.position_at_offset(offset),
|
||||
Some(TablePosition::OnTab { .. } | TablePosition::OnNewline { .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Get all cells that intersect with the given offset range.
|
||||
/// Returns cells in row-major order.
|
||||
pub fn cells_in_range(&self, start: CharOffset, end: CharOffset) -> Vec<CellRange> {
|
||||
self.cell_ranges
|
||||
.iter()
|
||||
.filter(|cell| cell.end > start && cell.start < end)
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the number of rows in the table.
|
||||
pub fn num_rows(&self) -> usize {
|
||||
self.num_rows
|
||||
}
|
||||
|
||||
/// Get the number of columns in the table.
|
||||
pub fn num_cols(&self) -> usize {
|
||||
self.num_cols
|
||||
}
|
||||
|
||||
fn separator_position(&self, cell: CellRange) -> TablePosition {
|
||||
let row_len = self
|
||||
.cell_index_by_row_col
|
||||
.get(cell.row)
|
||||
.map(|cells| cells.len())
|
||||
.unwrap_or(0);
|
||||
if cell.col + 1 < row_len {
|
||||
TablePosition::OnTab {
|
||||
row: cell.row,
|
||||
after_col: cell.col,
|
||||
}
|
||||
} else {
|
||||
TablePosition::OnNewline { row: cell.row }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: When we add editable tables or other complex table operations, consider moving
|
||||
// cell/row boundaries into the `SumTree` with new `BufferText` marker types so that
|
||||
// per-cell offsets can be derived by seeking to boundaries instead of re-parsing the
|
||||
// whole table on every edit. The current embedded-text-plus-cached-parse model is
|
||||
// sufficient for read-only tables; see PR #24326 discussion for context.
|
||||
impl TableCellOffsetMap {
|
||||
/// Build a cell offset map from the raw cell `source` text and the parsed `inline`
|
||||
/// fragments produced by the Markdown parser.
|
||||
///
|
||||
/// This walks `source` character-by-character alongside the rendered text of each fragment,
|
||||
/// which makes it robust to:
|
||||
/// - backslash escapes (e.g. `\*foo` consumes two source chars for one rendered char),
|
||||
/// - changes to Markdown marker syntax (we don't hardcode `**`, `*`, `<u>`, etc. here), and
|
||||
/// - nested styles where adjacent fragments share outer markers (e.g. `**a *b* c**`), since we
|
||||
/// attribute each marker to the fragment whose rendered text follows it.
|
||||
pub fn from_inline_and_source(source: &str, inline: &[FormattedTextFragment]) -> Self {
|
||||
let source_chars: Vec<char> = source.chars().collect();
|
||||
let total_source_chars = source_chars.len();
|
||||
let mut fragment_ranges: Vec<TableCellFragmentRange> = Vec::new();
|
||||
let mut rendered_offset = CharOffset::zero();
|
||||
let mut source_idx: usize = 0;
|
||||
|
||||
for fragment in inline {
|
||||
let rendered_chars: Vec<char> = fragment.text.chars().collect();
|
||||
if rendered_chars.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let first_rendered = rendered_chars[0];
|
||||
while source_idx < total_source_chars {
|
||||
let sc = source_chars[source_idx];
|
||||
if sc == '\\'
|
||||
&& source_idx + 1 < total_source_chars
|
||||
&& source_chars[source_idx + 1] == first_rendered
|
||||
{
|
||||
source_idx += 1;
|
||||
break;
|
||||
}
|
||||
if sc == first_rendered {
|
||||
break;
|
||||
}
|
||||
source_idx += 1;
|
||||
}
|
||||
|
||||
let visible_source_start = CharOffset::from(source_idx);
|
||||
|
||||
for &rendered_char in &rendered_chars {
|
||||
if source_idx >= total_source_chars {
|
||||
break;
|
||||
}
|
||||
let sc = source_chars[source_idx];
|
||||
if sc == '\\'
|
||||
&& source_idx + 1 < total_source_chars
|
||||
&& source_chars[source_idx + 1] == rendered_char
|
||||
{
|
||||
source_idx += 2;
|
||||
} else {
|
||||
source_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let visible_source_end = CharOffset::from(source_idx);
|
||||
let rendered_start = rendered_offset;
|
||||
let rendered_end = rendered_start + CharOffset::from(rendered_chars.len());
|
||||
|
||||
fragment_ranges.push(TableCellFragmentRange {
|
||||
rendered_start,
|
||||
rendered_end,
|
||||
source_end: visible_source_end,
|
||||
visible_source_start,
|
||||
visible_source_end,
|
||||
});
|
||||
|
||||
rendered_offset = rendered_end;
|
||||
}
|
||||
|
||||
let total_source_offset = CharOffset::from(total_source_chars);
|
||||
let fragment_count = fragment_ranges.len();
|
||||
for i in 0..fragment_count {
|
||||
let next_start = if i + 1 < fragment_count {
|
||||
fragment_ranges[i + 1].visible_source_start
|
||||
} else {
|
||||
total_source_offset
|
||||
};
|
||||
fragment_ranges[i].source_end = next_start;
|
||||
}
|
||||
|
||||
Self {
|
||||
fragment_ranges,
|
||||
rendered_length: rendered_offset,
|
||||
source_length: total_source_offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rendered_length(&self) -> CharOffset {
|
||||
self.rendered_length
|
||||
}
|
||||
|
||||
pub fn source_length(&self) -> CharOffset {
|
||||
self.source_length
|
||||
}
|
||||
|
||||
pub fn rendered_to_source(&self, rendered_offset: CharOffset) -> CharOffset {
|
||||
if rendered_offset >= self.rendered_length {
|
||||
return self
|
||||
.fragment_ranges
|
||||
.last()
|
||||
.map(|fragment| fragment.visible_source_end)
|
||||
.unwrap_or(self.source_length);
|
||||
}
|
||||
|
||||
for fragment in &self.fragment_ranges {
|
||||
if rendered_offset < fragment.rendered_end {
|
||||
return fragment.visible_source_start + (rendered_offset - fragment.rendered_start);
|
||||
}
|
||||
}
|
||||
|
||||
self.source_length
|
||||
}
|
||||
|
||||
pub fn source_to_rendered(&self, source_offset: CharOffset) -> CharOffset {
|
||||
if source_offset >= self.source_length {
|
||||
return self.rendered_length;
|
||||
}
|
||||
|
||||
for fragment in &self.fragment_ranges {
|
||||
if source_offset < fragment.source_end {
|
||||
if source_offset <= fragment.visible_source_start {
|
||||
return fragment.rendered_start;
|
||||
}
|
||||
if source_offset >= fragment.visible_source_end {
|
||||
return fragment.rendered_end;
|
||||
}
|
||||
return fragment.rendered_start + (source_offset - fragment.visible_source_start);
|
||||
}
|
||||
}
|
||||
|
||||
self.rendered_length
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "table_offset_map_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,261 @@
|
||||
use super::*;
|
||||
use markdown_parser::Hyperlink;
|
||||
use markdown_parser::parse_inline_markdown;
|
||||
use markdown_parser::weight::CustomWeight;
|
||||
|
||||
#[test]
|
||||
fn test_simple_table() {
|
||||
let map = TableOffsetMap::new(vec![vec![1, 2], vec![3, 1]]);
|
||||
|
||||
assert_eq!(map.total_length(), CharOffset::from(11));
|
||||
assert_eq!(map.num_rows(), 2);
|
||||
assert_eq!(map.num_cols(), 2);
|
||||
|
||||
assert!(matches!(
|
||||
map.position_at_offset(CharOffset::from(0)),
|
||||
Some(TablePosition::InCell { row: 0, col: 0, .. })
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
map.position_at_offset(CharOffset::from(1)),
|
||||
Some(TablePosition::OnTab {
|
||||
row: 0,
|
||||
after_col: 0
|
||||
})
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
map.position_at_offset(CharOffset::from(2)),
|
||||
Some(TablePosition::InCell { row: 0, col: 1, .. })
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
map.position_at_offset(CharOffset::from(4)),
|
||||
Some(TablePosition::OnNewline { row: 0 })
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
map.position_at_offset(CharOffset::from(5)),
|
||||
Some(TablePosition::InCell { row: 1, col: 0, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cell_at_offset() {
|
||||
let map = TableOffsetMap::new(vec![vec![3, 3]]);
|
||||
|
||||
assert_eq!(
|
||||
map.cell_at_offset(CharOffset::from(0)),
|
||||
Some(CellAtOffset {
|
||||
row: 0,
|
||||
col: 0,
|
||||
offset_in_cell: CharOffset::from(0)
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
map.cell_at_offset(CharOffset::from(2)),
|
||||
Some(CellAtOffset {
|
||||
row: 0,
|
||||
col: 0,
|
||||
offset_in_cell: CharOffset::from(2)
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
map.cell_at_offset(CharOffset::from(4)),
|
||||
Some(CellAtOffset {
|
||||
row: 0,
|
||||
col: 1,
|
||||
offset_in_cell: CharOffset::from(0)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_out_of_bounds_offset() {
|
||||
let map = TableOffsetMap::new(vec![vec![2, 2]]);
|
||||
assert!(map.position_at_offset(map.total_length()).is_none());
|
||||
assert!(map.position_at_offset(CharOffset::from(100)).is_none());
|
||||
assert!(map.cell_at_offset(map.total_length()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_separator() {
|
||||
let map = TableOffsetMap::new(vec![vec![1, 1], vec![1, 1]]);
|
||||
assert!(!map.is_separator(CharOffset::from(0)));
|
||||
assert!(map.is_separator(CharOffset::from(1)));
|
||||
assert!(map.is_separator(CharOffset::from(3)));
|
||||
assert!(!map.is_separator(CharOffset::from(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_cells() {
|
||||
let map = TableOffsetMap::new(vec![vec![0, 3], vec![2, 0]]);
|
||||
assert_eq!(map.num_rows(), 2);
|
||||
assert_eq!(map.num_cols(), 2);
|
||||
|
||||
assert!(matches!(
|
||||
map.position_at_offset(CharOffset::from(0)),
|
||||
Some(TablePosition::OnTab {
|
||||
row: 0,
|
||||
after_col: 0
|
||||
})
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
map.position_at_offset(CharOffset::from(1)),
|
||||
Some(TablePosition::InCell { row: 0, col: 1, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cells_in_range() {
|
||||
let map = TableOffsetMap::new(vec![vec![2, 2], vec![2, 2]]);
|
||||
let cells = map.cells_in_range(CharOffset::from(0), map.total_length());
|
||||
assert_eq!(cells.len(), 4);
|
||||
|
||||
let first_row = map.cells_in_range(CharOffset::from(0), CharOffset::from(5));
|
||||
assert_eq!(first_row.len(), 2);
|
||||
assert_eq!(first_row[0].row, 0);
|
||||
assert_eq!(first_row[0].col, 0);
|
||||
assert_eq!(first_row[1].row, 0);
|
||||
assert_eq!(first_row[1].col, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cell_range() {
|
||||
let map = TableOffsetMap::new(vec![vec![3, 2]]);
|
||||
assert_eq!(
|
||||
map.cell_range(0, 0),
|
||||
Some(CellOffsetRange {
|
||||
start: CharOffset::from(0),
|
||||
end: CharOffset::from(3)
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
map.cell_range(0, 1),
|
||||
Some(CellOffsetRange {
|
||||
start: CharOffset::from(4),
|
||||
end: CharOffset::from(6)
|
||||
})
|
||||
);
|
||||
assert!(map.cell_range(0, 2).is_none());
|
||||
assert!(map.cell_range(1, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_cell_offset_map_handles_bold_and_links() {
|
||||
let source = "**Bold** [Link](https://warp.dev)";
|
||||
let inline = parse_inline_markdown(source);
|
||||
assert!(
|
||||
inline.iter().any(|fragment| fragment
|
||||
.styles
|
||||
.weight
|
||||
.is_some_and(|weight| matches!(weight, CustomWeight::Bold))),
|
||||
"parsed inline should have a bold fragment"
|
||||
);
|
||||
assert!(
|
||||
inline
|
||||
.iter()
|
||||
.any(|fragment| matches!(&fragment.styles.hyperlink, Some(Hyperlink::Url(url)) if url == "https://warp.dev")),
|
||||
"parsed inline should have a hyperlink fragment"
|
||||
);
|
||||
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
|
||||
|
||||
assert_eq!(map.rendered_length(), CharOffset::from(9));
|
||||
assert_eq!(
|
||||
map.source_length(),
|
||||
CharOffset::from(source.chars().count())
|
||||
);
|
||||
assert_eq!(
|
||||
map.rendered_to_source(CharOffset::from(0)),
|
||||
CharOffset::from(2)
|
||||
);
|
||||
assert_eq!(
|
||||
map.rendered_to_source(CharOffset::from(4)),
|
||||
CharOffset::from(8)
|
||||
);
|
||||
assert_eq!(
|
||||
map.rendered_to_source(CharOffset::from(5)),
|
||||
CharOffset::from(10)
|
||||
);
|
||||
assert_eq!(
|
||||
map.rendered_to_source(CharOffset::from(9)),
|
||||
CharOffset::from(14)
|
||||
);
|
||||
assert_eq!(
|
||||
map.source_to_rendered(CharOffset::from(0)),
|
||||
CharOffset::from(0)
|
||||
);
|
||||
assert_eq!(
|
||||
map.source_to_rendered(CharOffset::from(2)),
|
||||
CharOffset::from(0)
|
||||
);
|
||||
assert_eq!(
|
||||
map.source_to_rendered(CharOffset::from(11)),
|
||||
CharOffset::from(6)
|
||||
);
|
||||
assert_eq!(
|
||||
map.source_to_rendered(CharOffset::from(14)),
|
||||
CharOffset::from(9)
|
||||
);
|
||||
assert_eq!(
|
||||
map.source_to_rendered(CharOffset::from(32)),
|
||||
CharOffset::from(9)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_cell_offset_map_handles_backslash_escaped_punctuation() {
|
||||
let source = "a \\*star\\* b";
|
||||
let inline = parse_inline_markdown(source);
|
||||
let rendered_text: String = inline
|
||||
.iter()
|
||||
.map(|fragment| fragment.text.as_str())
|
||||
.collect();
|
||||
assert_eq!(rendered_text, "a *star* b");
|
||||
|
||||
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
|
||||
assert_eq!(
|
||||
map.rendered_length(),
|
||||
CharOffset::from(rendered_text.chars().count())
|
||||
);
|
||||
assert_eq!(
|
||||
map.source_length(),
|
||||
CharOffset::from(source.chars().count())
|
||||
);
|
||||
assert_eq!(
|
||||
map.rendered_to_source(CharOffset::from(rendered_text.chars().count())),
|
||||
CharOffset::from(source.chars().count())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_cell_offset_map_handles_nested_styles() {
|
||||
let source = "**a *b* c**";
|
||||
let inline = parse_inline_markdown(source);
|
||||
let rendered_text: String = inline
|
||||
.iter()
|
||||
.map(|fragment| fragment.text.as_str())
|
||||
.collect();
|
||||
assert_eq!(rendered_text, "a b c");
|
||||
|
||||
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
|
||||
assert_eq!(
|
||||
map.source_length(),
|
||||
CharOffset::from(source.chars().count())
|
||||
);
|
||||
assert_eq!(
|
||||
map.rendered_length(),
|
||||
CharOffset::from(rendered_text.chars().count())
|
||||
);
|
||||
for (rendered_idx, rendered_char) in rendered_text.chars().enumerate() {
|
||||
let source_pos = map.rendered_to_source(CharOffset::from(rendered_idx));
|
||||
assert_eq!(
|
||||
source.chars().nth(source_pos.as_usize()),
|
||||
Some(rendered_char),
|
||||
"rendered {rendered_idx} ({rendered_char:?}) should map to same char in source",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Test helpers for all render model tests.
|
||||
|
||||
use parking_lot::Once;
|
||||
use std::{mem, sync::Arc};
|
||||
use vec1::{Vec1, vec1};
|
||||
|
||||
use crate::content::text::BufferBlockStyle;
|
||||
use ordered_float::OrderedFloat;
|
||||
use warpui::elements::ListIndentLevel;
|
||||
use warpui::{
|
||||
color::ColorU,
|
||||
elements::{Border, Fill},
|
||||
fonts::{FamilyId, Weight},
|
||||
geometry::vector::vec2f,
|
||||
text_layout::{CaretPosition, Glyph, Line, Run, TextFrame},
|
||||
units::{IntoPixels, Pixels},
|
||||
};
|
||||
|
||||
use super::{
|
||||
BlockItem, BrokenLinkStyle, CheckBoxStyle, DEFAULT_BLOCK_SPACINGS, HorizontalRuleStyle,
|
||||
InlineCodeStyle, OffsetMap, PARAGRAPH_MIN_HEIGHT, Paragraph, ParagraphStyles, RichTextStyles,
|
||||
TEXT_SPACING, TableStyle,
|
||||
};
|
||||
|
||||
pub const TEST_BASELINE_OFFSET: f32 = 0.7;
|
||||
|
||||
/// Create a new paragraph that occupies the given space but has no content.
|
||||
pub fn mock_paragraph(height: f32, width: f32, content_length: usize) -> BlockItem {
|
||||
let frame = TextFrame::new(
|
||||
vec1![Line {
|
||||
width,
|
||||
trailing_whitespace_width: 0.,
|
||||
runs: Vec::new(),
|
||||
// The line's effective height is determined by its font size and line height
|
||||
// ratio, so set those to produce the expected height,
|
||||
font_size: height,
|
||||
line_height_ratio: 1.,
|
||||
baseline_ratio: TEST_BASELINE_OFFSET,
|
||||
ascent: height * TEST_BASELINE_OFFSET,
|
||||
descent: height * (1. - TEST_BASELINE_OFFSET),
|
||||
clip_config: None,
|
||||
caret_positions: Vec::new(),
|
||||
chars_with_missing_glyphs: Vec::new(),
|
||||
}],
|
||||
width,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
BlockItem::paragraph(
|
||||
Arc::new(frame),
|
||||
OffsetMap::direct(content_length),
|
||||
content_length.into(),
|
||||
TEXT_SPACING,
|
||||
Some(PARAGRAPH_MIN_HEIGHT),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new paragraph block laid out via [`layout`].
|
||||
pub fn laid_out_paragraph(
|
||||
text: &str,
|
||||
styles: &RichTextStyles,
|
||||
max_width: impl IntoPixels,
|
||||
) -> BlockItem {
|
||||
BlockItem::Paragraph(layout_paragraph(
|
||||
text,
|
||||
styles,
|
||||
&BufferBlockStyle::PlainText,
|
||||
max_width,
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a new paragraph block laid out via [`layout`].
|
||||
pub fn laid_out_unordered_lists(
|
||||
text: &str,
|
||||
styles: &RichTextStyles,
|
||||
max_width: impl IntoPixels,
|
||||
) -> BlockItem {
|
||||
BlockItem::UnorderedList {
|
||||
indent_level: ListIndentLevel::One,
|
||||
paragraph: layout_paragraph(
|
||||
text,
|
||||
styles,
|
||||
&BufferBlockStyle::UnorderedList {
|
||||
indent_level: ListIndentLevel::One,
|
||||
},
|
||||
max_width,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out a single paragraph of text.
|
||||
pub fn layout_paragraph(
|
||||
text: &str,
|
||||
styles: &RichTextStyles,
|
||||
block_style: &BufferBlockStyle,
|
||||
max_width: impl IntoPixels,
|
||||
) -> Paragraph {
|
||||
let Some(text) = text.strip_suffix('\n') else {
|
||||
panic!("Laid out paragraph should end with newline");
|
||||
};
|
||||
let content_length = text.chars().count() + 1; // Add back 1 for the newline.
|
||||
Paragraph::new(
|
||||
Arc::new(layout(text, styles, max_width)),
|
||||
OffsetMap::direct(content_length),
|
||||
content_length.into(),
|
||||
vec![],
|
||||
styles.block_spacings.from_block_style(block_style),
|
||||
Some(PARAGRAPH_MIN_HEIGHT),
|
||||
)
|
||||
}
|
||||
|
||||
/// Lays out each hard-wrapped paragraph in `text`
|
||||
pub fn layout_paragraphs(
|
||||
text: &str,
|
||||
styles: &RichTextStyles,
|
||||
block_style: &BufferBlockStyle,
|
||||
max_width: impl IntoPixels + Copy,
|
||||
) -> Vec1<Paragraph> {
|
||||
Vec1::try_from_vec(
|
||||
text.split('\n')
|
||||
.map(|line| {
|
||||
let frame = Arc::new(layout(line, styles, max_width));
|
||||
let content_length = line.chars().count() + 1; // Add back 1 for the newline.
|
||||
Paragraph::new(
|
||||
frame,
|
||||
OffsetMap::direct(content_length),
|
||||
content_length.into(),
|
||||
vec![],
|
||||
styles.block_spacings.from_block_style(block_style),
|
||||
Some(PARAGRAPH_MIN_HEIGHT),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.expect("Should have at least one paragraph")
|
||||
}
|
||||
|
||||
/// Static default color, since [`ColorU`] constructors aren't `const`.
|
||||
const WHITE: ColorU = ColorU {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
pub const TEST_STYLES: RichTextStyles = RichTextStyles {
|
||||
base_text: ParagraphStyles {
|
||||
font_family: FamilyId(0),
|
||||
font_size: 10.,
|
||||
font_weight: Weight::Normal,
|
||||
line_height_ratio: 1.,
|
||||
text_color: WHITE,
|
||||
baseline_ratio: TEST_BASELINE_OFFSET,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
code_text: ParagraphStyles {
|
||||
font_family: FamilyId(0),
|
||||
font_size: 10.,
|
||||
font_weight: Weight::Normal,
|
||||
line_height_ratio: 1.,
|
||||
text_color: WHITE,
|
||||
baseline_ratio: TEST_BASELINE_OFFSET,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_background: Fill::None,
|
||||
embedding_background: Fill::None,
|
||||
embedding_text: ParagraphStyles {
|
||||
font_family: FamilyId(0),
|
||||
font_size: 10.,
|
||||
font_weight: Weight::Normal,
|
||||
line_height_ratio: 1.,
|
||||
text_color: WHITE,
|
||||
baseline_ratio: TEST_BASELINE_OFFSET,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_border: Border::new(0.),
|
||||
placeholder_color: WHITE,
|
||||
selection_fill: Fill::None,
|
||||
cursor_fill: Fill::None,
|
||||
inline_code_style: InlineCodeStyle {
|
||||
font_family: FamilyId(0),
|
||||
background: WHITE,
|
||||
font_color: WHITE,
|
||||
},
|
||||
check_box_style: CheckBoxStyle {
|
||||
border_width: 2.,
|
||||
border_color: WHITE,
|
||||
icon_path: "bundled/svg/check-thick.svg",
|
||||
background: WHITE,
|
||||
hover_background: WHITE,
|
||||
},
|
||||
horizontal_rule_style: HorizontalRuleStyle {
|
||||
rule_height: 2.,
|
||||
color: WHITE,
|
||||
},
|
||||
broken_link_style: BrokenLinkStyle {
|
||||
icon_path: "bundled/svg/link-broken-02.svg",
|
||||
icon_color: WHITE,
|
||||
},
|
||||
block_spacings: DEFAULT_BLOCK_SPACINGS,
|
||||
show_placeholder_text_on_empty_block: false,
|
||||
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
|
||||
cursor_width: 1.,
|
||||
highlight_urls: true,
|
||||
table_style: TableStyle {
|
||||
border_color: WHITE,
|
||||
header_background: WHITE,
|
||||
cell_background: WHITE,
|
||||
alternate_row_background: None,
|
||||
text_color: WHITE,
|
||||
header_text_color: WHITE,
|
||||
scrollbar_nonactive_thumb_color: WHITE,
|
||||
scrollbar_active_thumb_color: WHITE,
|
||||
font_family: FamilyId(0),
|
||||
font_size: 10.,
|
||||
cell_padding: 8.0,
|
||||
outer_border: true,
|
||||
column_dividers: true,
|
||||
row_dividers: true,
|
||||
},
|
||||
};
|
||||
|
||||
/// Minimal text layout for unit tests that require populated text frames.
|
||||
///
|
||||
/// This implementation soft-wraps at `max_width`, but without any segmentation
|
||||
/// rules.
|
||||
pub fn layout(text: &str, styles: &RichTextStyles, max_width: impl IntoPixels) -> TextFrame {
|
||||
let max_width = max_width.into_pixels();
|
||||
// For simplicity, pretend characters are square.
|
||||
let char_width = styles.base_text.font_size.into_pixels();
|
||||
|
||||
let mut lines_acc = vec![];
|
||||
let mut glyphs_acc = vec![];
|
||||
let mut carets_acc = vec![];
|
||||
let mut line_width = Pixels::zero();
|
||||
for (index, ch) in text.chars().enumerate() {
|
||||
assert_ne!(ch, '\n', "Hard breaks not supported");
|
||||
|
||||
if line_width + char_width > max_width {
|
||||
lines_acc.push(Line {
|
||||
width: line_width.as_f32(),
|
||||
trailing_whitespace_width: 0.,
|
||||
runs: vec![Run {
|
||||
font_id: warpui::fonts::FontId(0),
|
||||
styles: Default::default(),
|
||||
glyphs: mem::take(&mut glyphs_acc),
|
||||
width: line_width.as_f32(),
|
||||
}],
|
||||
font_size: styles.base_text.font_size,
|
||||
line_height_ratio: styles.base_text.line_height_ratio,
|
||||
baseline_ratio: TEST_BASELINE_OFFSET,
|
||||
ascent: styles.base_text.font_size * TEST_BASELINE_OFFSET,
|
||||
descent: styles.base_text.font_size * (1. - TEST_BASELINE_OFFSET),
|
||||
clip_config: None,
|
||||
caret_positions: mem::take(&mut carets_acc),
|
||||
chars_with_missing_glyphs: Vec::new(),
|
||||
});
|
||||
line_width = Pixels::zero();
|
||||
}
|
||||
|
||||
glyphs_acc.push(Glyph {
|
||||
id: 0,
|
||||
position_along_baseline: vec2f(line_width.as_f32(), 0.),
|
||||
index,
|
||||
width: char_width.as_f32(),
|
||||
});
|
||||
|
||||
carets_acc.push(CaretPosition {
|
||||
position_in_line: line_width.as_f32(),
|
||||
start_offset: index,
|
||||
last_offset: index,
|
||||
});
|
||||
|
||||
line_width += char_width;
|
||||
}
|
||||
|
||||
// Push any remaining characters (or an empty line, if the text frame was empty).
|
||||
if !glyphs_acc.is_empty() || lines_acc.is_empty() {
|
||||
lines_acc.push(Line {
|
||||
width: line_width.as_f32(),
|
||||
trailing_whitespace_width: 0.,
|
||||
runs: vec![Run {
|
||||
font_id: warpui::fonts::FontId(0),
|
||||
styles: Default::default(),
|
||||
glyphs: glyphs_acc,
|
||||
width: line_width.as_f32(),
|
||||
}],
|
||||
font_size: styles.base_text.font_size,
|
||||
line_height_ratio: styles.base_text.line_height_ratio,
|
||||
baseline_ratio: TEST_BASELINE_OFFSET,
|
||||
ascent: styles.base_text.font_size * TEST_BASELINE_OFFSET,
|
||||
descent: styles.base_text.font_size * (1. - TEST_BASELINE_OFFSET),
|
||||
clip_config: None,
|
||||
caret_positions: carets_acc,
|
||||
chars_with_missing_glyphs: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let max_width = lines_acc
|
||||
.iter()
|
||||
.map(|line| OrderedFloat(line.width))
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
match Vec1::try_from_vec(lines_acc) {
|
||||
Ok(lines) => TextFrame::new(lines, max_width.into_inner(), Default::default()),
|
||||
Err(_) => TextFrame::empty(
|
||||
styles.base_text.font_size,
|
||||
styles.base_text.line_height_ratio,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize logging for tests. This should be called at the start of any test that needs logging.
|
||||
pub fn init_logging() {
|
||||
// If multiple tests run in the same process, we should still only set up logging once.
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
env_logger::builder()
|
||||
.parse_filters("warp_editor=trace")
|
||||
.is_test(true)
|
||||
.init();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
use float_cmp::ApproxEq;
|
||||
use sum_tree::{SeekBias, SumTree};
|
||||
use warpui::{
|
||||
SizeConstraint,
|
||||
geometry::{
|
||||
rect::RectF,
|
||||
vector::{Vector2F, vec2f},
|
||||
},
|
||||
units::{IntoPixels, Pixels},
|
||||
};
|
||||
|
||||
use crate::render::element::RenderContext;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use super::{
|
||||
AUTO_SCROLL_MARGIN, BlockItem, BlockSpacing, Height, HitTestOptions, LayoutSummary, Location,
|
||||
RenderState, UNIT_MARGIN, bounds, positioned::PositionedCursor,
|
||||
};
|
||||
|
||||
/// For horizontal autoscrolling, it is very easy to "stuck" on a character if it is aligned exactly on the viewport boundary.
|
||||
/// To help make scrolling more smooth, add a small margin here to overcome these boundaries.
|
||||
const HORIZONTAL_SCROLL_MARGIN: f32 = 4.;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewport_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ViewportState {
|
||||
/// Width of the viewport. Currently, we soft-wrap text to fit this. However,
|
||||
/// we'll eventually support horizontal scrolling if the viewport is narrower
|
||||
/// than some minimum content width.
|
||||
width: Pixels,
|
||||
/// Height of the viewport. All scrolling and viewporting is in terms of
|
||||
/// pixels, not lines, as the line height varies for different content.
|
||||
height: Pixels,
|
||||
|
||||
/// Vertical scrolling offset. This is the distance from the start of the
|
||||
/// content (height 0) to the first visible content.
|
||||
scroll_top: Pixels,
|
||||
|
||||
/// Horizontal scrolling offset.
|
||||
scroll_left: Pixels,
|
||||
}
|
||||
|
||||
/// A visible, viewported item. This stores all the information needed to lay out and display a
|
||||
/// block and any associated UI controls in the current viewport.
|
||||
///
|
||||
/// Because the viewport item is needed throughout the `Element` lifecycle, it does not directly
|
||||
/// reference the rendering model. Instead, it holds offsets that refer back to the model, relying
|
||||
/// on the UI framework to guarantee that the model does not change without a re-render.
|
||||
#[derive(Debug)]
|
||||
pub struct ViewportItem {
|
||||
/// The y-offset to display this item at, relative to the viewport origin.
|
||||
/// If this is negative, the item is partially above the viewport.
|
||||
pub viewport_offset: Pixels,
|
||||
/// The y-offset of this item, relative to the content origin.
|
||||
pub content_offset: Pixels,
|
||||
/// The size of this item's content, in pixels.
|
||||
pub content_size: Vector2F,
|
||||
/// Spacing around this item.
|
||||
pub spacing: BlockSpacing,
|
||||
/// Offset of the start of the block backing this item.
|
||||
pub block_offset: CharOffset,
|
||||
}
|
||||
|
||||
/// A snapshot of the scroll position. This may only be used to scroll back to the original
|
||||
/// position, and cannot be inspected.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScrollPositionSnapshot {
|
||||
/// The offset of the top left character in the viewport. We use this to represent the scroll
|
||||
/// position, rather than a line count, to be resilient to soft-wrapping changes. If the
|
||||
/// viewport is resized, then the content that a given line offset refers to will likely be
|
||||
/// different.
|
||||
first_character_offset: CharOffset,
|
||||
}
|
||||
|
||||
impl ScrollPositionSnapshot {
|
||||
/// Map this snapshot back to a `scroll_top` offset for the current render state.
|
||||
pub(super) fn to_scroll_top(self, render_state: &RenderState) -> Pixels {
|
||||
render_state
|
||||
.character_bounds(self.first_character_offset)
|
||||
.map_or(Pixels::zero(), |bounds| bounds.min_y().into_pixels())
|
||||
}
|
||||
|
||||
/// Snapshot the render state's current scroll position.
|
||||
pub(super) fn from_scroll_top(render_state: &RenderState) -> Self {
|
||||
let first_character_offset = match render_state.viewport_coordinates_to_location(
|
||||
Pixels::zero(),
|
||||
Pixels::zero(),
|
||||
&HitTestOptions {
|
||||
force_text_selection: true,
|
||||
},
|
||||
) {
|
||||
Location::Text { char_offset, .. } => char_offset,
|
||||
Location::Block { start_offset, .. } => start_offset,
|
||||
};
|
||||
Self {
|
||||
first_character_offset,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn first_character_offset(self) -> CharOffset {
|
||||
self.first_character_offset
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ViewportIterator<'a> {
|
||||
cursor: sum_tree::Cursor<'a, BlockItem, Height, LayoutSummary>,
|
||||
/// The starting y-offset of content to display.
|
||||
content_start: Pixels,
|
||||
/// The ending y-offset of content to display (exclusive). This may be past
|
||||
/// the end of the document, but it just needs to be an upper bound.
|
||||
content_end: Pixels,
|
||||
/// Maximum width the painted object could take in the current viewport.
|
||||
max_width: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SizeInfo {
|
||||
/// The size of the viewport, in pixels.
|
||||
pub viewport_size: Vector2F,
|
||||
|
||||
/// Whether or not text must be laid out again to fit the new viewport size.
|
||||
pub needs_layout: bool,
|
||||
}
|
||||
|
||||
impl ViewportState {
|
||||
/// Create a new `ViewportState` with the given viewport size, scrolled to
|
||||
/// the top of the document.
|
||||
pub fn new(width: Pixels, height: Pixels) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
scroll_top: Pixels::zero(),
|
||||
scroll_left: Pixels::zero(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the viewport. When rendering, it's assumed that the UI
|
||||
/// element is this wide.
|
||||
pub fn width(&self) -> Pixels {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// Height of the viewport. When rendering, it's assumed that the UI element
|
||||
/// is this tall.
|
||||
pub fn height(&self) -> Pixels {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// The current vertical scroll position of the viewport.
|
||||
pub fn scroll_top(&self) -> Pixels {
|
||||
self.scroll_top
|
||||
}
|
||||
|
||||
/// The current horizontal scroll position of the viewport.
|
||||
pub fn scroll_left(&self) -> Pixels {
|
||||
self.scroll_left
|
||||
}
|
||||
|
||||
/// Vertically scroll by `delta` pixels. Scrolling is capped at `content_height`,
|
||||
/// which should be the height of the buffer content.
|
||||
///
|
||||
/// Returns whether the view should be re-rendered.
|
||||
pub(super) fn scroll(&mut self, delta: Pixels, content_height: Pixels) -> bool {
|
||||
self.scroll_to(self.scroll_top - delta, content_height)
|
||||
}
|
||||
|
||||
pub(super) fn scroll_horizontally(&mut self, delta: Pixels, content_width: Pixels) -> bool {
|
||||
self.scroll_horizontally_to(self.scroll_left - delta, content_width)
|
||||
}
|
||||
|
||||
/// Scroll to the given `scroll_top`, clamped to the end of the buffer.
|
||||
///
|
||||
/// Returns whether or not the view needs to be re-rendered.
|
||||
pub(super) fn scroll_to(&mut self, scroll_top: Pixels, content_height: Pixels) -> bool {
|
||||
let scroll_top = self.clamp_scroll_offset(scroll_top, content_height, self.height);
|
||||
let changed = scroll_top.approx_ne(self.scroll_top, UNIT_MARGIN);
|
||||
if changed {
|
||||
self.scroll_top = scroll_top;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(super) fn scroll_horizontally_to(
|
||||
&mut self,
|
||||
scroll_left: Pixels,
|
||||
content_width: Pixels,
|
||||
) -> bool {
|
||||
let scroll_left = self.clamp_scroll_offset(scroll_left, content_width, self.width);
|
||||
let changed = scroll_left.approx_ne(self.scroll_left, UNIT_MARGIN);
|
||||
if changed {
|
||||
self.scroll_left = scroll_left;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Set the scroll position to an exact location.
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_scroll_top(&mut self, scroll_top: Pixels) {
|
||||
self.scroll_top = scroll_top;
|
||||
}
|
||||
|
||||
/// Notifies the viewport model that the content height has changed, which
|
||||
/// affects the range of valid scroll positions.
|
||||
///
|
||||
/// Returns whether the view should be re-rendered.
|
||||
pub(super) fn update_content_height(&mut self, content_height: Pixels) -> bool {
|
||||
// A scroll of 0 will reapply the clamping logic to ensure the scroll
|
||||
// position is still in bounds.
|
||||
self.scroll(Pixels::zero(), content_height)
|
||||
}
|
||||
|
||||
pub(super) fn update_content_width(&mut self, content_width: Pixels) -> bool {
|
||||
// A scroll of 0 will reapply the clamping logic to ensure the scroll
|
||||
// position is still in bounds.
|
||||
self.scroll_horizontally(Pixels::zero(), content_width)
|
||||
}
|
||||
|
||||
pub(super) fn autoscroll(
|
||||
&mut self,
|
||||
item_start: Vector2F,
|
||||
item_end: Vector2F,
|
||||
content_height: Pixels,
|
||||
content_width: Pixels,
|
||||
should_autoscroll_horizontally: bool,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
if should_autoscroll_horizontally {
|
||||
if (item_start.x() - HORIZONTAL_SCROLL_MARGIN).into_pixels() < self.scroll_left {
|
||||
changed = self.scroll_horizontally(
|
||||
self.scroll_left - item_start.x().into_pixels()
|
||||
+ AUTO_SCROLL_MARGIN.into_pixels(),
|
||||
content_width,
|
||||
) || changed;
|
||||
} else if (item_end.x() + HORIZONTAL_SCROLL_MARGIN).into_pixels()
|
||||
> self.scroll_left + self.width
|
||||
{
|
||||
changed = self.scroll_horizontally(
|
||||
self.scroll_left - item_end.x().into_pixels() + self.width
|
||||
- AUTO_SCROLL_MARGIN.into_pixels(),
|
||||
content_width,
|
||||
) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
if item_start.y().into_pixels() < self.scroll_top {
|
||||
// The position we want to scroll to is `item_start - AUTO_SCROLL_MARGIN.into_pixels()`.
|
||||
changed = self.scroll(
|
||||
self.scroll_top - item_start.y().into_pixels() + AUTO_SCROLL_MARGIN.into_pixels(),
|
||||
content_height,
|
||||
) || changed;
|
||||
} else if item_end.y().into_pixels() > self.scroll_top + self.height {
|
||||
// The position we want to scroll to is `item_end - self.height + AUTO_SCROLL_MARGIN.into_pixels()`.
|
||||
changed = self.scroll(
|
||||
self.scroll_top - item_end.y().into_pixels() + self.height
|
||||
- AUTO_SCROLL_MARGIN.into_pixels(),
|
||||
content_height,
|
||||
) || changed;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Clamps a scroll position to a valid value. The scroll top must be positive,
|
||||
/// and is at most the content height minus the viewport height. The viewport
|
||||
/// is scrolled all the way to the top if the scroll position is 0 and
|
||||
/// all the way to the bottom if the last viewport's worth of content is
|
||||
/// visible.
|
||||
fn clamp_scroll_offset(
|
||||
&self,
|
||||
scroll_top: Pixels,
|
||||
content_height: Pixels,
|
||||
viewport_height: Pixels,
|
||||
) -> Pixels {
|
||||
scroll_top
|
||||
.min(content_height - viewport_height)
|
||||
.max(Pixels::zero())
|
||||
}
|
||||
|
||||
/// Calculates the viewport size given layout constraints.
|
||||
///
|
||||
/// Because we do not have mutable model access when laying out UI elements,
|
||||
/// size changes are handled in two steps:
|
||||
/// 1. [`crate::render::element::RichTextElement`] calls `viewport_size` as
|
||||
/// part of its `layout` implementation.
|
||||
/// 2. `RichTextElement` then updates the model with the size it computed
|
||||
/// in `after_layout`. Since `after_layout` runs before painting and
|
||||
/// event handling, the model still has enough information to viewport,
|
||||
/// hit-test, and scroll.
|
||||
pub(in crate::render) fn viewport_size(
|
||||
&self,
|
||||
constraint: SizeConstraint,
|
||||
size_buffer: Vector2F,
|
||||
max_width: Option<Pixels>,
|
||||
) -> SizeInfo {
|
||||
// TODO(ben): We should have a minimum soft-wrap width. If the constraint's
|
||||
// maximum size is below this, we start horizontal scrolling rather
|
||||
// than trying to soft-wrap further.
|
||||
|
||||
let mut max_constraint = constraint.max;
|
||||
if let Some(max_width) = max_width {
|
||||
max_constraint.set_x(constraint.max.x().min(max_width.as_f32()));
|
||||
}
|
||||
|
||||
let content_constraint = SizeConstraint::new(
|
||||
(constraint.min - size_buffer).max(Vector2F::zero()),
|
||||
(max_constraint - size_buffer).max(Vector2F::zero()),
|
||||
);
|
||||
|
||||
let width = content_constraint.max.x();
|
||||
let height = content_constraint.max.y();
|
||||
|
||||
let needs_layout = width.approx_ne(self.width.as_f32(), UNIT_MARGIN);
|
||||
|
||||
SizeInfo {
|
||||
viewport_size: vec2f(width, height),
|
||||
needs_layout,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the viewport size that was calculated by a call to [`viewport_size`]
|
||||
/// during layout. This should only be called by [`crate::render::element::RichTextElement`],
|
||||
/// otherwise there's no guarantee that content is soft-wrapped to the correct bounds.
|
||||
///
|
||||
/// This may also adjust the scroll position, if it's not valid in the new viewport size.
|
||||
pub(super) fn set_size(
|
||||
&mut self,
|
||||
size: Vector2F,
|
||||
content_width: Pixels,
|
||||
content_height: Pixels,
|
||||
) {
|
||||
self.width = size.x().into_pixels();
|
||||
self.height = size.y().into_pixels();
|
||||
// If set_size is called, the view is already being re-rendered, so we can ignore the
|
||||
// return value of update_content_height.
|
||||
self.update_content_height(content_height);
|
||||
self.update_content_width(content_width);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ViewportIterator<'a> {
|
||||
/// Begin an iterator over the current viewport.
|
||||
pub(super) fn new(
|
||||
content: &'a SumTree<BlockItem>,
|
||||
scroll_top: Pixels,
|
||||
viewport_height: Pixels,
|
||||
viewport_width: Pixels,
|
||||
) -> Self {
|
||||
let mut cursor = content.cursor();
|
||||
cursor.seek_clamped(&scroll_top.into(), SeekBias::Left);
|
||||
|
||||
Self {
|
||||
cursor,
|
||||
content_start: scroll_top,
|
||||
content_end: scroll_top + viewport_height,
|
||||
max_width: viewport_width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for ViewportIterator<'a> {
|
||||
type Item = (ViewportItem, &'a BlockItem);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let item = self.cursor.positioned_item()?;
|
||||
// Stop rendering once the current item is completely outside the viewport.
|
||||
if item.start_y_offset > self.content_end {
|
||||
return None;
|
||||
}
|
||||
self.cursor.next();
|
||||
|
||||
let spacing = item.item.spacing();
|
||||
let content_width = self.max_width - spacing.x_axis_offset();
|
||||
let viewport_item = ViewportItem {
|
||||
viewport_offset: item.start_y_offset - self.content_start,
|
||||
content_offset: item.start_y_offset,
|
||||
content_size: vec2f(content_width.as_f32(), item.item.content_height().as_f32()),
|
||||
spacing,
|
||||
block_offset: item.start_char_offset,
|
||||
};
|
||||
Some((viewport_item, item.item))
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewportItem {
|
||||
/// The block backing this viewport item.
|
||||
pub fn block_offset(&self) -> CharOffset {
|
||||
self.block_offset
|
||||
}
|
||||
|
||||
pub fn height(&self) -> f64 {
|
||||
// We sometimes encounter floating point errors when since we are seeking exactly on the edge of
|
||||
// a block item. Add a small buffer here so we could consistently seek to the right element.
|
||||
self.content_offset.as_f32() as f64 + 0.1
|
||||
}
|
||||
|
||||
/// The content bounds of this item (see [`bounds::content_box`]).
|
||||
pub fn content_bounds(&self, ctx: &RenderContext) -> RectF {
|
||||
ctx.content_rect_to_screen(bounds::content_box(
|
||||
self.content_offset,
|
||||
self.content_size,
|
||||
&self.spacing,
|
||||
))
|
||||
}
|
||||
|
||||
/// The visible bounds of this item (see [`bounds::visible_box`]).
|
||||
pub fn visible_bounds(&self, ctx: &RenderContext) -> RectF {
|
||||
ctx.content_rect_to_screen(bounds::visible_box(
|
||||
self.content_offset,
|
||||
self.content_size,
|
||||
&self.spacing,
|
||||
))
|
||||
}
|
||||
|
||||
/// The reserved bounds of this item (see [`bounds::reserved_box`]).
|
||||
pub fn reserved_bounds(&self, ctx: &RenderContext) -> RectF {
|
||||
ctx.content_rect_to_screen(bounds::reserved_box(
|
||||
self.content_offset,
|
||||
self.content_size,
|
||||
&self.spacing,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! extract_block {
|
||||
($viewport_item:expr, $content:expr, $match:pat => $value:expr) => {{
|
||||
let offset = $viewport_item.block_offset();
|
||||
match $content.block_at_offset(offset) {
|
||||
Some(block) => match (&block, block.item) {
|
||||
$match => $value,
|
||||
other => {
|
||||
log::trace!("Unexpected block {other:?} at {}", offset);
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => return,
|
||||
}
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use itertools::Itertools;
|
||||
use sum_tree::SumTree;
|
||||
|
||||
use warpui::{
|
||||
SizeConstraint,
|
||||
geometry::vector::vec2f,
|
||||
units::{IntoPixels, Pixels},
|
||||
};
|
||||
|
||||
use crate::render::model::{
|
||||
RenderState,
|
||||
test_utils::{TEST_STYLES, mock_paragraph},
|
||||
};
|
||||
|
||||
use super::ViewportState;
|
||||
|
||||
#[test]
|
||||
fn test_viewport_offsets() {
|
||||
let mut render_state =
|
||||
RenderState::new_for_test(TEST_STYLES, 20.0.into_pixels(), 180.0.into_pixels());
|
||||
let mut content = SumTree::new();
|
||||
content.push(mock_paragraph(10., 100., 1));
|
||||
content.push(mock_paragraph(60., 100., 1));
|
||||
content.push(mock_paragraph(100., 100., 2));
|
||||
content.push(mock_paragraph(30., 100., 3));
|
||||
content.push(mock_paragraph(80., 100., 1));
|
||||
render_state.set_content(content);
|
||||
|
||||
// Double-check the heights with margins+padding, as later tests rely on them.
|
||||
let heights = render_state
|
||||
.content
|
||||
.borrow()
|
||||
.items()
|
||||
.iter()
|
||||
.map(|item| item.height().as_f32())
|
||||
.collect_vec();
|
||||
assert_eq!(heights, vec![32., 68., 108., 38., 88., 32.]);
|
||||
|
||||
let content = render_state.content();
|
||||
let offsets = content
|
||||
.viewport_items(
|
||||
180.0.into_pixels(),
|
||||
render_state.viewport().width(),
|
||||
40.0.into_pixels(),
|
||||
)
|
||||
.map(|(item, _)| (item.viewport_offset.as_f32(), item.block_offset.as_usize()))
|
||||
.collect_vec();
|
||||
|
||||
// Each item should be painted starting at the sum of all previous heights,
|
||||
// offset by scroll_top
|
||||
assert_eq!(
|
||||
offsets,
|
||||
vec![
|
||||
// The first item is fully above the viewport.
|
||||
// The second item is slightly above the viewport.
|
||||
(-8., 1),
|
||||
// The third is fully within the viewport
|
||||
(60., 2),
|
||||
// The fourth is slightly past the viewport, and cut off.
|
||||
(168., 4)
|
||||
] // The fifth item is fully after the viewport.
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_item_to_block() {
|
||||
let mut render_state =
|
||||
RenderState::new_for_test(TEST_STYLES, 100.0.into_pixels(), 160.0.into_pixels());
|
||||
let mut content = SumTree::new();
|
||||
content.push(mock_paragraph(80., 100., 10));
|
||||
content.push(mock_paragraph(20., 100., 3));
|
||||
render_state.set_content(content);
|
||||
|
||||
let viewport_items = {
|
||||
let content = render_state.content();
|
||||
content
|
||||
.viewport_items(
|
||||
160.0.into_pixels(),
|
||||
render_state.viewport().width(),
|
||||
0.0.into_pixels(),
|
||||
)
|
||||
.map(|(item, _)| item)
|
||||
.collect_vec()
|
||||
};
|
||||
|
||||
let content = render_state.content();
|
||||
let block0 = content
|
||||
.block_at_offset(viewport_items[0].block_offset())
|
||||
.expect("Block should exist");
|
||||
assert_eq!(block0.start_char_offset, 0.into());
|
||||
|
||||
let block1 = content
|
||||
.block_at_offset(viewport_items[1].block_offset())
|
||||
.expect("Block should exist");
|
||||
assert_eq!(block1.start_char_offset, 10.into());
|
||||
drop(content);
|
||||
|
||||
// Now, invalidate the items by pushing new content. They should detect this and fail.
|
||||
let mut new_tree = SumTree::new();
|
||||
new_tree.push(mock_paragraph(40., 100., 4));
|
||||
new_tree.push_tree(render_state.content.borrow().clone());
|
||||
render_state.set_content(new_tree);
|
||||
|
||||
let content = render_state.content();
|
||||
// There's still _a_ block at char offset 0, which is what's returned here. This is fine, since
|
||||
// the check in ViewportItem::block is a fallback in case of programmer error.
|
||||
assert!(
|
||||
content
|
||||
.block_at_offset(viewport_items[0].block_offset())
|
||||
.is_some()
|
||||
);
|
||||
// However, the next item no longer has a backing block.
|
||||
assert!(
|
||||
content
|
||||
.block_at_offset(viewport_items[1].block_offset())
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_bounds() {
|
||||
let content_height = 32.0.into_pixels();
|
||||
let mut state = ViewportState::new(8.0.into_pixels(), 16.0.into_pixels());
|
||||
|
||||
// Scroll down within the document.
|
||||
assert!(state.scroll((-6.0).into_pixels(), content_height));
|
||||
assert_eq!(state.scroll_top.as_f32(), 6.);
|
||||
|
||||
// Now, scroll up, but past the beginning of the document. The scroll_top
|
||||
// should clamp at 0.
|
||||
assert!(state.scroll(100.0.into_pixels(), content_height));
|
||||
assert_eq!(state.scroll_top.as_f32(), 0.);
|
||||
|
||||
// Now, scroll back down.
|
||||
assert!(state.scroll((-12.0).into_pixels(), content_height));
|
||||
assert_eq!(state.scroll_top.as_f32(), 12.);
|
||||
|
||||
// We can keep scrolling, but it's clamped to the last viewport of the document.
|
||||
assert!(state.scroll((-100.0).into_pixels(), content_height));
|
||||
assert_eq!(state.scroll_top.as_f32(), 16.);
|
||||
|
||||
// If we try to scroll more, it has no effect.
|
||||
assert!(!state.scroll((-10.0).into_pixels(), content_height));
|
||||
assert_eq!(state.scroll_top.as_f32(), 16.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_width_change() {
|
||||
let state = ViewportState::new(100.0.into_pixels(), 500.0.into_pixels());
|
||||
|
||||
// Shrink the viewport by 20 pixels horizontally. It should use the maximum space available,
|
||||
// and need layout because of the size change.
|
||||
let size_info = state.viewport_size(
|
||||
SizeConstraint::new(vec2f(20., 100.), vec2f(80., 500.)),
|
||||
vec2f(0., 0.),
|
||||
None,
|
||||
);
|
||||
assert!(size_info.needs_layout);
|
||||
assert_eq!(size_info.viewport_size, vec2f(80., 500.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_height_change() {
|
||||
let content_height = 300.0.into_pixels();
|
||||
let content_width = 100.0.into_pixels();
|
||||
let mut state = ViewportState::new(100.0.into_pixels(), 200.0.into_pixels());
|
||||
|
||||
// Scroll the viewport.
|
||||
state.scroll(-(50.0.into_pixels()), content_height);
|
||||
assert_eq!(state.scroll_top, 50.0.into_pixels());
|
||||
|
||||
// Resize the viewport such that it no longer has a scrollbar.
|
||||
state.set_size(vec2f(100., 400.), content_width, content_height);
|
||||
assert_eq!(state.scroll_top, Pixels::zero());
|
||||
|
||||
// Resize the viewport to need scrolling again. This won't autoscroll, however.
|
||||
state.set_size(vec2f(100., 100.), content_width, content_height);
|
||||
assert_eq!(state.scroll_top, Pixels::zero());
|
||||
|
||||
// Scroll, and then shrink the viewport further. This should preserve the scroll position.
|
||||
state.scroll(-(50.0.into_pixels()), content_height);
|
||||
state.set_size(vec2f(100., 80.), content_width, content_height);
|
||||
assert_eq!(state.scroll_top, 50.0.into_pixels());
|
||||
|
||||
// The scroll_top cannot be past the last viewport of content. If the viewport extends such
|
||||
// that the scroll position is invalid, but a scrollbar is still needed, we'll scroll up slightly.
|
||||
state.set_size(vec2f(100., 260.), content_width, content_height);
|
||||
assert_eq!(state.scroll_top, 40.0.into_pixels());
|
||||
}
|
||||
Reference in New Issue
Block a user