264 lines
9.9 KiB
Rust
264 lines
9.9 KiB
Rust
//! Hit-testing implementation for the rendering model.
|
|
|
|
use galaxyui::units::{IntoPixels, Pixels};
|
|
use num_traits::SaturatingSub;
|
|
use string_offset::CharOffset;
|
|
use sum_tree::SeekBias;
|
|
use galaxyui_core::units::{IntoPixels, Pixels};
|
|
|
|
use super::positioned::{Positioned, PositionedCursor};
|
|
use super::{
|
|
BlockItem, Height, HitTestBlockType, LayoutSummary, ParagraphBlock, RenderState, bounds,
|
|
};
|
|
|
|
#[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())
|
|
}
|
|
}
|