Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
use enum_iterator::Sequence;
|
||||
use markdown_parser::weight::CustomWeight;
|
||||
|
||||
/// Header sizes for formatted text blocks.
|
||||
#[derive(Eq, PartialEq, Clone, Copy, Debug, Hash, Sequence)]
|
||||
pub enum BlockHeaderSize {
|
||||
Header1,
|
||||
Header2,
|
||||
Header3,
|
||||
Header4,
|
||||
Header5,
|
||||
Header6,
|
||||
}
|
||||
|
||||
impl BlockHeaderSize {
|
||||
/// Get font size multiplication ratio for this heading level.
|
||||
pub fn font_size_multiplication_ratio(self) -> f32 {
|
||||
// The WHATWG HTML living standard is a useful starting point for these, but we don't
|
||||
// follow it exactly:
|
||||
// https://html.spec.whatwg.org/multipage/rendering.html#sections-and-headings
|
||||
match self {
|
||||
Self::Header1 => 2.25,
|
||||
Self::Header2 => 1.8,
|
||||
Self::Header3 => 1.5,
|
||||
Self::Header4 => 1.0,
|
||||
Self::Header5 => 0.83,
|
||||
Self::Header6 => 0.67,
|
||||
}
|
||||
}
|
||||
|
||||
/// Font weight for this heading level.
|
||||
pub fn font_weight(self) -> Option<CustomWeight> {
|
||||
match self {
|
||||
Self::Header1 | Self::Header2 | Self::Header3 | Self::Header4 => {
|
||||
Some(CustomWeight::Semibold)
|
||||
}
|
||||
Self::Header5 | Self::Header6 => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text label for this heading, in the format `Heading $N`.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
BlockHeaderSize::Header1 => "Heading 1",
|
||||
BlockHeaderSize::Header2 => "Heading 2",
|
||||
BlockHeaderSize::Header3 => "Heading 3",
|
||||
BlockHeaderSize::Header4 => "Heading 4",
|
||||
BlockHeaderSize::Header5 => "Heading 5",
|
||||
BlockHeaderSize::Header6 => "Heading 6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockHeaderSize> for usize {
|
||||
fn from(header_size: BlockHeaderSize) -> Self {
|
||||
match header_size {
|
||||
BlockHeaderSize::Header1 => 1,
|
||||
BlockHeaderSize::Header2 => 2,
|
||||
BlockHeaderSize::Header3 => 3,
|
||||
BlockHeaderSize::Header4 => 4,
|
||||
BlockHeaderSize::Header5 => 5,
|
||||
BlockHeaderSize::Header6 => 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<usize> for BlockHeaderSize {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(header_size: usize) -> Result<Self, Self::Error> {
|
||||
match header_size {
|
||||
1 => Ok(BlockHeaderSize::Header1),
|
||||
2 => Ok(BlockHeaderSize::Header2),
|
||||
3 => Ok(BlockHeaderSize::Header3),
|
||||
4 => Ok(BlockHeaderSize::Header4),
|
||||
5 => Ok(BlockHeaderSize::Header5),
|
||||
6 => Ok(BlockHeaderSize::Header6),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use itertools::Itertools;
|
||||
use string_offset::{ByteOffset, CharCounter, CharOffset};
|
||||
|
||||
use crate::event::ModifiersState;
|
||||
|
||||
use self::point::Point;
|
||||
|
||||
use self::word_boundaries::WordBoundaries;
|
||||
|
||||
pub mod header;
|
||||
pub mod point;
|
||||
pub mod word_boundaries;
|
||||
pub mod words;
|
||||
|
||||
pub use header::BlockHeaderSize;
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum SelectionType {
|
||||
#[default]
|
||||
Simple,
|
||||
Semantic,
|
||||
Lines,
|
||||
Rect,
|
||||
}
|
||||
|
||||
impl SelectionType {
|
||||
pub fn from_click_count(click_count: u32) -> Self {
|
||||
match click_count {
|
||||
0 => SelectionType::Simple,
|
||||
1 => SelectionType::Simple,
|
||||
2 => SelectionType::Semantic,
|
||||
3 => SelectionType::Lines,
|
||||
_ => SelectionType::Lines,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_mouse_event(modifiers: ModifiersState, click_count: u32) -> Self {
|
||||
let is_rect = if cfg!(target_os = "macos") {
|
||||
modifiers.cmd && modifiers.alt
|
||||
} else {
|
||||
modifiers.ctrl && modifiers.alt
|
||||
};
|
||||
|
||||
if is_rect {
|
||||
return SelectionType::Rect;
|
||||
}
|
||||
|
||||
SelectionType::from_click_count(click_count)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SelectionType> for IsRect {
|
||||
fn from(selection_type: SelectionType) -> Self {
|
||||
match selection_type {
|
||||
SelectionType::Rect => IsRect::True,
|
||||
_ => IsRect::False,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
|
||||
pub enum IsRect {
|
||||
True,
|
||||
#[default]
|
||||
False,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub enum SelectionDirection {
|
||||
#[default]
|
||||
Forward,
|
||||
Backward,
|
||||
}
|
||||
|
||||
/// A buffer of text characters. This trait acts as a base layer to implement text segmentation
|
||||
/// on top of. Currently, it supports word navigation.
|
||||
pub trait TextBuffer {
|
||||
type Chars<'a>: Iterator<Item = char> + 'a
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
type CharsReverse<'a>: Iterator<Item = char> + 'a
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
/// Begin iterating over the characters at `offset`, continuing to the end of the buffer.
|
||||
///
|
||||
/// The maximum valid `offset` is the length of the buffer (not 1 less than the length). This
|
||||
/// allows starting just after the last character.
|
||||
fn chars_at(&self, offset: CharOffset) -> Result<Self::Chars<'_>>;
|
||||
|
||||
/// Begin iterating backwards over the characters from `offset` to the start of the buffer.
|
||||
///
|
||||
/// Note that this is _different_ from the semantics of `Iterator::rev`, which would instead
|
||||
/// start at the very end of the buffer.
|
||||
///
|
||||
/// The maximum valid `offset` is the length of the buffer (not 1 less than the length). This
|
||||
/// allows starting just after the last character.
|
||||
fn chars_rev_at(&self, offset: CharOffset) -> Result<Self::CharsReverse<'_>>;
|
||||
|
||||
/// Converts a character offset to a buffer [`Point`], if it is in bounds.
|
||||
fn to_point(&self, offset: CharOffset) -> Result<Point>;
|
||||
|
||||
/// Convert a point to its offset within the buffer.
|
||||
fn to_offset(&self, point: Point) -> Result<CharOffset>;
|
||||
|
||||
/// Get an iterator of word starting points forward from the given offset
|
||||
fn word_starts_from_offset<T: BufferIndex>(
|
||||
&self,
|
||||
position: T,
|
||||
) -> Result<WordBoundaries<'_, Self>> {
|
||||
let offset = position.to_char_offset(self)?;
|
||||
Ok(WordBoundaries::forward_starts(
|
||||
offset,
|
||||
self.chars_at(offset)?,
|
||||
self,
|
||||
))
|
||||
}
|
||||
|
||||
/// Get an iterator of word ending points forward from the given offset, excluding the current
|
||||
/// location if it is a word boundary.
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
|
||||
/// the 'word'), this will yield columns [8, 12, 18], the ends of `one`, `two`, and `three`,
|
||||
/// but _excluding_ the initial position at the end of `word`.
|
||||
fn word_ends_from_offset_exclusive<T: BufferIndex>(
|
||||
&self,
|
||||
position: T,
|
||||
) -> Result<WordBoundaries<'_, Self>> {
|
||||
let offset = position.to_char_offset(self)?;
|
||||
Ok(WordBoundaries::forward_ends_exclusive(
|
||||
offset,
|
||||
self.chars_at(offset)?,
|
||||
self,
|
||||
))
|
||||
}
|
||||
|
||||
/// Get an iterator of word ending points forward from the given offset, including the current
|
||||
/// location if appropriate.
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
|
||||
/// the 'word'), this will yield columns [4, 8, 12, 18], the ends of all four words,
|
||||
/// _including_ the initial position at the end of `word`.
|
||||
fn word_ends_from_offset_inclusive<T: BufferIndex>(
|
||||
&self,
|
||||
position: T,
|
||||
) -> Result<WordBoundaries<'_, Self>> {
|
||||
let offset = position.to_char_offset(self)?;
|
||||
Ok(WordBoundaries::forward_ends_inclusive(
|
||||
offset,
|
||||
self.chars_at(offset)?,
|
||||
self,
|
||||
))
|
||||
}
|
||||
|
||||
/// Get an iterator of word starting points backwards from the given offset, excluding the
|
||||
/// current location if it is a word boundary.
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
|
||||
/// the 'three'), this will yield columns [9, 5, 0], the starts of `two`, `one`, and `word`,
|
||||
/// but _excluding_ the initial position at the start of `three`.
|
||||
fn word_starts_backward_from_offset_exclusive<T: BufferIndex>(
|
||||
&self,
|
||||
position: T,
|
||||
) -> Result<WordBoundaries<'_, Self>> {
|
||||
let offset = position.to_char_offset(self)?;
|
||||
Ok(WordBoundaries::backward_starts_exclusive(
|
||||
offset,
|
||||
self.chars_rev_at(offset)?,
|
||||
self,
|
||||
))
|
||||
}
|
||||
|
||||
/// Get an iterator of word starting points backwards from the given offset, including the
|
||||
/// current location if appropriate.
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
|
||||
/// the 'three'), this will yield columns [13, 9, 5, 0], the starts of all four words,
|
||||
/// _including_ the initial position at the start of `three`.
|
||||
fn word_starts_backward_from_offset_inclusive<T: BufferIndex>(
|
||||
&self,
|
||||
position: T,
|
||||
) -> Result<WordBoundaries<'_, Self>> {
|
||||
let offset = position.to_char_offset(self)?;
|
||||
Ok(WordBoundaries::backward_starts_inclusive(
|
||||
offset,
|
||||
self.chars_rev_at(offset)?,
|
||||
self,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// A type which can index into a text buffer.
|
||||
pub trait BufferIndex {
|
||||
fn to_char_offset<B: TextBuffer + ?Sized>(&self, buffer: &B) -> Result<CharOffset>;
|
||||
}
|
||||
|
||||
impl BufferIndex for CharOffset {
|
||||
fn to_char_offset<B: TextBuffer + ?Sized>(&self, _: &B) -> Result<CharOffset> {
|
||||
Ok(*self)
|
||||
}
|
||||
}
|
||||
|
||||
impl BufferIndex for Point {
|
||||
fn to_char_offset<B: TextBuffer + ?Sized>(&self, buffer: &B) -> Result<CharOffset> {
|
||||
buffer.to_offset(*self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TextBuffer for str {
|
||||
type Chars<'a> = std::str::Chars<'a>;
|
||||
type CharsReverse<'a> = std::iter::Rev<std::str::Chars<'a>>;
|
||||
|
||||
fn chars_at(&self, offset: CharOffset) -> Result<Self::Chars<'_>> {
|
||||
let chars = self.chars().count();
|
||||
if offset.as_usize() <= chars {
|
||||
Ok(self.chars().dropping(offset.as_usize()))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Offset {offset} out of bounds; char length is {chars}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn chars_rev_at(&self, offset: CharOffset) -> Result<Self::CharsReverse<'_>> {
|
||||
let chars = self.chars().count();
|
||||
if offset.as_usize() <= chars {
|
||||
Ok(self.chars().rev().dropping(chars - offset.as_usize()))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Offset {offset} out of bounds; char length is {chars}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_point(&self, offset: CharOffset) -> Result<Point> {
|
||||
let chars = self.chars().count();
|
||||
if offset.as_usize() <= chars {
|
||||
Ok(Point::new(0, offset.as_usize() as u32))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Offset {offset} out of bounds; char length is {chars}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_offset(&self, point: Point) -> Result<CharOffset> {
|
||||
if point.row == 0 {
|
||||
let chars = self.chars().count();
|
||||
if (point.column as usize) <= chars {
|
||||
Ok(CharOffset::from(point.column as usize))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Column {} out of bounds; char length is {chars}",
|
||||
point.column
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Row {} out of bounds; str only has 1 row",
|
||||
point.row
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a slice of text into a `Vec` of UTF-8 bytes.
|
||||
pub fn str_to_byte_vec(text: &str) -> Vec<u8> {
|
||||
text.as_bytes().iter().cloned().collect_vec()
|
||||
}
|
||||
|
||||
/// Slice a string by [`char`] offsets, rather than byte offsets.
|
||||
///
|
||||
/// The starting index is inclusive, while the ending index is exclusive.
|
||||
pub fn char_slice(s: &str, start: usize, end: usize) -> Option<&str> {
|
||||
if end < start {
|
||||
return None;
|
||||
}
|
||||
|
||||
if start == end {
|
||||
return Some("");
|
||||
}
|
||||
|
||||
let mut indices = s.char_indices();
|
||||
let (start_index, _) = indices.nth(start)?;
|
||||
// Why not just use `nth()` again? We need to distinguish between a `None` because `end`
|
||||
// is out of bounds and a `None` because `end` is the end of the string.
|
||||
// If/when Iterator::advance_by (https://github.com/rust-lang/rust/issues/77404) stabilizes,
|
||||
// we should use that. In the meantime, this doesn't hurt performance because `nth()`
|
||||
// also has to advance character-by-character.
|
||||
for _ in start + 1..end {
|
||||
indices.next()?;
|
||||
}
|
||||
|
||||
let end_index = match indices.next() {
|
||||
Some((index, _)) => index,
|
||||
None => s.len(),
|
||||
};
|
||||
|
||||
s.get(start_index..end_index)
|
||||
}
|
||||
|
||||
pub fn count_chars_up_to_byte(text: &str, byte_offset: ByteOffset) -> Option<CharOffset> {
|
||||
if byte_offset.as_usize() == text.len() {
|
||||
return Some(CharOffset::from(text.chars().count()));
|
||||
}
|
||||
let mut counter = CharCounter::new(text);
|
||||
counter.char_offset(byte_offset)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,64 @@
|
||||
use super::{
|
||||
count_chars_up_to_byte,
|
||||
point::Point,
|
||||
{char_slice, BufferIndex, TextBuffer},
|
||||
};
|
||||
|
||||
use super::str_to_byte_vec;
|
||||
|
||||
#[test]
|
||||
fn test_str_to_byte_vec() {
|
||||
assert_eq!(
|
||||
str_to_byte_vec("foo bar"),
|
||||
vec![0x66, 0x6f, 0x6f, 0x20, 0x62, 0x61, 0x72]
|
||||
);
|
||||
}
|
||||
|
||||
/// Test the [`str`] implementation of [`TextBuffer`], which we rely on in other unit tests.
|
||||
#[test]
|
||||
fn test_str_buffer() -> anyhow::Result<()> {
|
||||
let buf = "Hello\nWorld!";
|
||||
|
||||
assert_eq!(buf.chars_at(2.into())?.collect::<String>(), "llo\nWorld!");
|
||||
assert_eq!(buf.chars_rev_at(3.into())?.collect::<String>(), "leH");
|
||||
|
||||
// For simplicity, we do not wrap newlines into new rows.
|
||||
assert_eq!(buf.to_point(7.into())?, Point::new(0, 7));
|
||||
assert!(Point::new(1, 1).to_char_offset(buf).is_err());
|
||||
assert_eq!(Point::new(0, 7).to_char_offset(buf)?, 7.into());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_char_slice() {
|
||||
let has_nonbreaking_space = "A\u{a0}non-breaking space occupies 2 bytes in UTF-8";
|
||||
assert_eq!(char_slice(has_nonbreaking_space, 0, 3), Some("A\u{a0}n"));
|
||||
|
||||
// This string has characters ['A', '❤', '\u{fe0f}', '\u{200d}', '🔥', 'b']
|
||||
assert_eq!(char_slice("A❤️🔥b", 4, 5), Some("🔥"));
|
||||
|
||||
assert_eq!(char_slice("abc", 5, 10), None);
|
||||
assert_eq!(char_slice("abc", 2, 0), None);
|
||||
assert_eq!(char_slice("abc", 1, 4), None);
|
||||
|
||||
assert_eq!(char_slice("A string", 2, 4), Some("st"));
|
||||
|
||||
assert_eq!(char_slice("The end: 🫥??", 10, 12), Some("??"));
|
||||
|
||||
assert_eq!(char_slice("🫥", 0, 0), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_char_counts_up_to_byte() {
|
||||
let text = "abc🔥abc☄️abc😬";
|
||||
assert_eq!(count_chars_up_to_byte(text, 0.into()), Some(0.into()));
|
||||
assert_eq!(
|
||||
count_chars_up_to_byte(text, "abc🔥".len().into()),
|
||||
Some(4.into())
|
||||
);
|
||||
assert_eq!(
|
||||
count_chars_up_to_byte(text, text.len().into()),
|
||||
Some(text.chars().count().into())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
ops::{Add, AddAssign, Sub},
|
||||
};
|
||||
|
||||
/// A point within a document. The exact coordinate system (whether or not rows
|
||||
/// are soft-wrapped, what the unit for characters is) is unspecified.
|
||||
#[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash)]
|
||||
pub struct Point {
|
||||
pub row: u32,
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub fn new(row: u32, column: u32) -> Self {
|
||||
Point { row, column }
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Point::new(0, 0)
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.row == 0 && self.column == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Point {
|
||||
type Output = Point;
|
||||
|
||||
fn add(self, other: Self) -> Self::Output {
|
||||
if other.row == 0 {
|
||||
Point::new(self.row, self.column + other.column)
|
||||
} else {
|
||||
Point::new(self.row + other.row, other.column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Point {
|
||||
type Output = Point;
|
||||
|
||||
fn sub(self, other: Self) -> Self::Output {
|
||||
debug_assert!(other <= self);
|
||||
|
||||
if self.row == other.row {
|
||||
Point::new(0, self.column - other.column)
|
||||
} else {
|
||||
Point::new(self.row - other.row, self.column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<Self> for Point {
|
||||
fn add_assign(&mut self, other: Self) {
|
||||
if other.row == 0 {
|
||||
self.column += other.column;
|
||||
} else {
|
||||
self.row += other.row;
|
||||
self.column = other.column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Point {
|
||||
fn partial_cmp(&self, other: &Point) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Point {
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
fn cmp(&self, other: &Point) -> Ordering {
|
||||
let a = (self.row as usize) << 32 | self.column as usize;
|
||||
let b = (other.row as usize) << 32 | other.column as usize;
|
||||
a.cmp(&b)
|
||||
}
|
||||
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
fn cmp(&self, other: &Point) -> Ordering {
|
||||
match self.row.cmp(&other.row) {
|
||||
Ordering::Equal => self.column.cmp(&other.column),
|
||||
comparison => comparison,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use std::iter::Peekable;
|
||||
use std::{borrow::Cow, collections::HashSet};
|
||||
|
||||
use itertools::Either;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use super::point::Point;
|
||||
|
||||
use super::words::is_default_word_boundary;
|
||||
use super::TextBuffer;
|
||||
|
||||
/// This enum configures how the WordBoundaries iterator defines a "word"
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WordBoundariesPolicy {
|
||||
/// Break words on spaces and the characters specified in words::is_default_word_boundary
|
||||
Default,
|
||||
/// Break words on spaces plus a specific set of provided characters
|
||||
Custom(HashSet<char>),
|
||||
/// Break words only on ASCII whitespace
|
||||
OnlyWhitespace,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum WordBoundariesApproach {
|
||||
ForwardWordStarts,
|
||||
ForwardWordEnds,
|
||||
BackwardWordStarts,
|
||||
}
|
||||
|
||||
/// Iterator that returns the edges of words from a given offset, based on the selected approach
|
||||
pub struct WordBoundaries<'a, T: TextBuffer + ?Sized> {
|
||||
offset: CharOffset,
|
||||
chars: Peekable<Either<T::Chars<'a>, T::CharsReverse<'a>>>,
|
||||
buffer: &'a T,
|
||||
in_word: bool,
|
||||
approach: WordBoundariesApproach,
|
||||
policy: Cow<'a, WordBoundariesPolicy>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<'a, T: TextBuffer + ?Sized> WordBoundaries<'a, T> {
|
||||
pub fn with_policy(mut self, policy: impl Into<Cow<'a, WordBoundariesPolicy>>) -> Self {
|
||||
self.policy = policy.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Create an iterator that will return the starts of words moving forwards
|
||||
pub fn forward_starts(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: Either::Left(chars).peekable(),
|
||||
in_word: true,
|
||||
approach: WordBoundariesApproach::ForwardWordStarts,
|
||||
policy: Cow::Owned(WordBoundariesPolicy::Default),
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an iterator that will return the ends of words moving forwards, exclusive of the
|
||||
/// offset position.
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
|
||||
/// the 'word'), this will yield columns [8, 12, 18], the ends of `one`, `two`, and `three`,
|
||||
/// but _excluding_ the initial position at the end of `word`.
|
||||
pub fn forward_ends_exclusive(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: Either::Left(chars).peekable(),
|
||||
in_word: false,
|
||||
approach: WordBoundariesApproach::ForwardWordEnds,
|
||||
policy: Cow::Owned(WordBoundariesPolicy::Default),
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an iterator that will return the ends of words moving forwards, inclusive of the
|
||||
/// offset position.
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
|
||||
/// the 'word'), this will yield columns [4, 8, 12, 18], the ends of all four words,
|
||||
/// _including_ the initial position at the end of `word`.
|
||||
pub fn forward_ends_inclusive(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: Either::Left(chars).peekable(),
|
||||
in_word: true,
|
||||
approach: WordBoundariesApproach::ForwardWordEnds,
|
||||
policy: Cow::Owned(WordBoundariesPolicy::Default),
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an iterator that will return the starts of words moving _backwards_, exclusive of
|
||||
/// the offset position
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
|
||||
/// the 'three'), this will yield columns [9, 5, 0], the starts of `two`, `one`, and `word`,
|
||||
/// but _excluding_ the initial position at the start of `three`.
|
||||
pub fn backward_starts_exclusive(
|
||||
offset: CharOffset,
|
||||
chars: T::CharsReverse<'a>,
|
||||
buffer: &'a T,
|
||||
) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: Either::Right(chars).peekable(),
|
||||
in_word: false,
|
||||
approach: WordBoundariesApproach::BackwardWordStarts,
|
||||
policy: Cow::Owned(WordBoundariesPolicy::Default),
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an iterator that will return the starts of words moving _backwards_, inclusive of
|
||||
/// the offset position
|
||||
///
|
||||
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
|
||||
/// the 'three'), this will yield columns [13, 9, 5, 0], the starts of all four words,
|
||||
/// _including_ the initial position at the start of `three`.
|
||||
pub fn backward_starts_inclusive(
|
||||
offset: CharOffset,
|
||||
chars: T::CharsReverse<'a>,
|
||||
buffer: &'a T,
|
||||
) -> Self {
|
||||
Self {
|
||||
offset,
|
||||
buffer,
|
||||
chars: Either::Right(chars).peekable(),
|
||||
in_word: true,
|
||||
approach: WordBoundariesApproach::BackwardWordStarts,
|
||||
policy: Cow::Owned(WordBoundariesPolicy::Default),
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self) {
|
||||
self.chars.next();
|
||||
match self.approach {
|
||||
WordBoundariesApproach::ForwardWordStarts | WordBoundariesApproach::ForwardWordEnds => {
|
||||
self.offset += 1;
|
||||
}
|
||||
WordBoundariesApproach::BackwardWordStarts => {
|
||||
self.offset -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_boundary(&self, c: char) -> bool {
|
||||
match self.policy.as_ref() {
|
||||
WordBoundariesPolicy::Default => is_default_word_boundary(c),
|
||||
WordBoundariesPolicy::Custom(boundary_chars) => {
|
||||
c.is_whitespace() || boundary_chars.contains(&c)
|
||||
}
|
||||
WordBoundariesPolicy::OnlyWhitespace => c.is_whitespace(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: TextBuffer + ?Sized> Iterator for WordBoundaries<'_, T> {
|
||||
type Item = Point;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
while let Some(&c) = self.chars.peek() {
|
||||
match self.approach {
|
||||
// For forward word starts, we look for the transition from not in a word (i.e. in
|
||||
// a separator) to in a word. That boundary is the start of a new word
|
||||
WordBoundariesApproach::ForwardWordStarts => {
|
||||
if self.in_word {
|
||||
self.step();
|
||||
|
||||
if self.is_word_boundary(c) {
|
||||
self.in_word = false;
|
||||
}
|
||||
} else if self.is_word_boundary(c) {
|
||||
self.step();
|
||||
} else {
|
||||
// We are not in a word, but the next character _is_ in a word, so
|
||||
// we've found the start of the next word. We mark ourselves as being
|
||||
// in a word (for the next iteration), then return the point.
|
||||
self.in_word = true;
|
||||
return self.buffer.to_point(self.offset).ok();
|
||||
}
|
||||
}
|
||||
// For forward word ends, we look for the transition from in a word to not in a
|
||||
// word. That boundary is the end of the current word. We also look for the same
|
||||
// boundary for backward starts, since going backwards the transition from in a
|
||||
// word to not in a word represents the _beginning_ of the current word
|
||||
WordBoundariesApproach::ForwardWordEnds
|
||||
| WordBoundariesApproach::BackwardWordStarts => {
|
||||
if self.in_word {
|
||||
if self.is_word_boundary(c) {
|
||||
// We are in a word, but the next character is _not_ in a word, so we
|
||||
// have found the boundary. We mark ourselves as not being in a word,
|
||||
// then return the point.
|
||||
self.in_word = false;
|
||||
return self.buffer.to_point(self.offset).ok();
|
||||
} else {
|
||||
self.step();
|
||||
}
|
||||
} else {
|
||||
self.step();
|
||||
|
||||
if !self.is_word_boundary(c) {
|
||||
self.in_word = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We have consumed all of the characters in the given direction. However, we should also
|
||||
// treat the end (or beginning if backward) of the buffer as a word boundary. We only want
|
||||
// to return that once, however, so we mark ourselves as done afterwards.
|
||||
if self.done {
|
||||
None
|
||||
} else {
|
||||
self.done = true;
|
||||
|
||||
self.buffer.to_point(self.offset).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WordBoundariesPolicy> for Cow<'_, WordBoundariesPolicy> {
|
||||
fn from(policy: WordBoundariesPolicy) -> Self {
|
||||
Cow::Owned(policy)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a WordBoundariesPolicy> for Cow<'a, WordBoundariesPolicy> {
|
||||
fn from(policy: &'a WordBoundariesPolicy) -> Self {
|
||||
Cow::Borrowed(policy)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "word_boundaries_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_word_boundaries() {
|
||||
let buffer = "test/c/ab/word_with_underscores {восибing}";
|
||||
|
||||
let starts: Vec<_> = buffer
|
||||
.word_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
starts,
|
||||
[
|
||||
Point::new(0, 5),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 10),
|
||||
Point::new(0, 33),
|
||||
Point::new(0, 42),
|
||||
]
|
||||
);
|
||||
|
||||
let ends: Vec<_> = buffer
|
||||
.word_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ends,
|
||||
[
|
||||
Point::new(0, 4),
|
||||
Point::new(0, 6),
|
||||
Point::new(0, 9),
|
||||
Point::new(0, 31),
|
||||
Point::new(0, 41),
|
||||
Point::new(0, 42),
|
||||
]
|
||||
);
|
||||
|
||||
let starts_only_space: Vec<_> = buffer
|
||||
.word_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.with_policy(WordBoundariesPolicy::OnlyWhitespace)
|
||||
.collect();
|
||||
assert_eq!(starts_only_space, [Point::new(0, 32), Point::new(0, 42)]);
|
||||
|
||||
let ends_only_space: Vec<_> = buffer
|
||||
.word_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.with_policy(WordBoundariesPolicy::OnlyWhitespace)
|
||||
.collect();
|
||||
assert_eq!(ends_only_space, [Point::new(0, 31), Point::new(0, 42)]);
|
||||
|
||||
let starts_custom: Vec<_> = buffer
|
||||
.word_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.with_policy(WordBoundariesPolicy::Custom(HashSet::from(['{', '}'])))
|
||||
.collect();
|
||||
assert_eq!(starts_custom, [Point::new(0, 33), Point::new(0, 42)]);
|
||||
|
||||
let ends_custom: Vec<_> = buffer
|
||||
.word_ends_from_offset_exclusive(Point::zero())
|
||||
.unwrap()
|
||||
.with_policy(WordBoundariesPolicy::Custom(HashSet::from(['{', '}'])))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ends_custom,
|
||||
[Point::new(0, 31), Point::new(0, 41), Point::new(0, 42)]
|
||||
);
|
||||
|
||||
let starts_reversed: Vec<_> = buffer
|
||||
.word_starts_backward_from_offset_exclusive(Point::new(0, 42))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
starts_reversed,
|
||||
[
|
||||
Point::new(0, 33),
|
||||
Point::new(0, 10),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 5),
|
||||
Point::new(0, 0),
|
||||
]
|
||||
);
|
||||
|
||||
let starts_mid: Vec<_> = buffer
|
||||
.word_starts_from_offset(Point::new(0, 7))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
starts_mid,
|
||||
[Point::new(0, 10), Point::new(0, 33), Point::new(0, 42),]
|
||||
);
|
||||
|
||||
let ends_mid: Vec<_> = buffer
|
||||
.word_ends_from_offset_exclusive(Point::new(0, 6))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ends_mid,
|
||||
[
|
||||
Point::new(0, 9),
|
||||
Point::new(0, 31),
|
||||
Point::new(0, 41),
|
||||
Point::new(0, 42),
|
||||
]
|
||||
);
|
||||
|
||||
let starts_reversed_mid: Vec<_> = buffer
|
||||
.word_starts_backward_from_offset_exclusive(Point::new(0, 8))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
starts_reversed_mid,
|
||||
[Point::new(0, 7), Point::new(0, 5), Point::new(0, 0),]
|
||||
);
|
||||
|
||||
let ends_inclusive: Vec<_> = buffer
|
||||
.word_ends_from_offset_inclusive(Point::new(0, 6))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ends_inclusive,
|
||||
[
|
||||
Point::new(0, 6),
|
||||
Point::new(0, 9),
|
||||
Point::new(0, 31),
|
||||
Point::new(0, 41),
|
||||
Point::new(0, 42),
|
||||
]
|
||||
);
|
||||
|
||||
let starts_reversed_inclusive: Vec<_> = buffer
|
||||
.word_starts_backward_from_offset_inclusive(Point::new(0, 10))
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
starts_reversed_inclusive,
|
||||
[
|
||||
Point::new(0, 10),
|
||||
Point::new(0, 7),
|
||||
Point::new(0, 5),
|
||||
Point::new(0, 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_whitespace() {
|
||||
// See https://en.wikipedia.org/wiki/Whitespace_character
|
||||
let text = "first\tsecond\u{A0}third\u{2003}fourth";
|
||||
let starts: Vec<_> = text
|
||||
.word_starts_from_offset(Point::zero())
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
starts,
|
||||
[
|
||||
Point::new(0, 6),
|
||||
Point::new(0, 13),
|
||||
Point::new(0, 19),
|
||||
Point::new(0, 25)
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// The default word-boundary characters.
|
||||
pub const DEFAULT_WORD_BOUNDARY_CHARS: [char; 33] = [
|
||||
'`', '~', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '=', '+', '[', '{', ']', '}',
|
||||
'\\', '|', ';', ':', '\'', '"', ',', '.', '<', '>', '/', '?', '«', '»',
|
||||
];
|
||||
|
||||
/// Default subword-boundary characters: basically just underscores for now (snake_case)
|
||||
pub const SUBWORD_BOUNDARY_CHARS: [char; 1] = ['_'];
|
||||
|
||||
/// Split a string slice at the next word boundary, returning before and after the word boundary
|
||||
///
|
||||
/// The next word boundary is the transition from not in a word (i.e. in a separator) to
|
||||
/// in a word. The first slice returned goes from start of the input slice to the word boundary.
|
||||
/// The second slice returns goes from the start of the new word to the end of the input slice.
|
||||
pub fn split_at_next_word_start(text: &str) -> (&str, &str) {
|
||||
let mut in_word = true;
|
||||
let mut byte_index = 0;
|
||||
for c in text.chars() {
|
||||
if in_word {
|
||||
if is_default_word_boundary(c) {
|
||||
in_word = false;
|
||||
}
|
||||
} else if !is_default_word_boundary(c) {
|
||||
break;
|
||||
}
|
||||
byte_index += c.len_utf8();
|
||||
}
|
||||
|
||||
text.split_at(byte_index)
|
||||
}
|
||||
|
||||
/// Default logic for determining if a character is a word separator. Word separators are
|
||||
/// whitespace or a specific set of punctuation characters.
|
||||
pub fn is_default_word_boundary(c: char) -> bool {
|
||||
c.is_whitespace() || DEFAULT_WORD_BOUNDARY_CHARS.contains(&c)
|
||||
}
|
||||
|
||||
/// Logic for determining if a character is a subword separator.
|
||||
/// Subword separators include all the default word separators
|
||||
/// (whitespace or a specific set of punctuation characters)
|
||||
/// and subword-specific separators (underscores, for snake_case).
|
||||
pub fn is_subword_boundary_char(c: char) -> bool {
|
||||
is_default_word_boundary(c) || SUBWORD_BOUNDARY_CHARS.contains(&c)
|
||||
}
|
||||
Reference in New Issue
Block a user