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_core::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, } /// 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) -> 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(&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 + Ord + Sub + 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) } }