use cranpose_foundation::text::TextRange;
pub const MULTI_TAP_TIMEOUT_MS: u128 = 500;
pub const MULTI_TAP_SLOP_PX: f32 = 24.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelectionGranularity {
Caret,
Word,
Line,
Paragraph,
}
pub fn classify_tap_count(
previous: Option<(u8, f32, f32)>,
elapsed_ms: u128,
x: f32,
y: f32,
timeout_ms: u128,
slop_px: f32,
) -> u8 {
let Some((prev_count, prev_x, prev_y)) = previous else {
return 1;
};
let within_time = elapsed_ms <= timeout_ms;
let dx = x - prev_x;
let dy = y - prev_y;
let within_slop = dx * dx + dy * dy <= slop_px * slop_px;
if !within_time || !within_slop {
return 1;
}
prev_count.saturating_add(1)
}
pub fn resolve_selection_tap_count(
raw_tap_count: u8,
previous_count: u8,
tap_in_selection: bool,
repeat_in_place: bool,
) -> u8 {
if raw_tap_count >= 2 {
raw_tap_count
} else if tap_in_selection {
if repeat_in_place {
previous_count.max(1).saturating_add(1)
} else {
2
}
} else {
raw_tap_count
}
}
pub fn tap_selection_granularity(tap_count: u8) -> SelectionGranularity {
match tap_count {
0 | 1 => SelectionGranularity::Caret,
n => match (n - 2) % 3 {
0 => SelectionGranularity::Word,
1 => SelectionGranularity::Line,
_ => SelectionGranularity::Paragraph,
},
}
}
pub fn granularity_boundaries(
text: &str,
pos: usize,
granularity: SelectionGranularity,
) -> (usize, usize) {
match granularity {
SelectionGranularity::Caret => (pos, pos),
SelectionGranularity::Word => crate::word_boundaries::find_word_boundaries(text, pos),
SelectionGranularity::Line => find_line_boundaries(text, pos),
SelectionGranularity::Paragraph => find_paragraph_boundaries(text, pos),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SelectionAnchor {
pub granularity: SelectionGranularity,
pub start: usize,
pub end: usize,
}
impl SelectionAnchor {
pub fn at(text: &str, pos: usize, granularity: SelectionGranularity) -> Self {
let (start, end) = granularity_boundaries(text, pos, granularity);
Self {
granularity,
start,
end,
}
}
pub fn dragged_to(self, text: &str, pos: usize) -> TextRange {
let (unit_start, unit_end) = granularity_boundaries(text, pos, self.granularity);
if unit_start < self.start {
TextRange::new(self.end, unit_start)
} else {
TextRange::new(self.start, unit_end.max(self.end))
}
}
}
pub fn find_line_boundaries(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos].rfind('\n').map_or(0, |i| i + 1);
let end = text[pos..].find('\n').map_or(text.len(), |i| pos + i);
(start, end)
}
pub fn find_paragraph_boundaries(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos].rfind("\n\n").map_or(0, |i| {
let mut s = i + 1;
while text[s..].starts_with('\n') {
s += 1;
}
s
});
let end = text[pos..].find("\n\n").map_or(text.len(), |i| pos + i);
(start.min(end), end)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineAffinity {
Upstream,
Downstream,
}
pub fn caret_visual_line(
ranges: &[std::ops::Range<usize>],
offset: usize,
affinity: LineAffinity,
) -> (usize, usize) {
let mut result = (0usize, 0usize);
for (index, range) in ranges.iter().enumerate() {
if range.start <= offset {
if affinity == LineAffinity::Upstream
&& index > 0
&& range.start == offset
&& ranges[index - 1].end == offset
&& ranges[index - 1].start < offset
{
break;
}
result = (index, range.start);
} else {
break;
}
}
result
}
pub const GRAB_DIRECT_FOLLOW_DISTANCE: f32 = 8.0;
pub const GRAB_VISIBILITY_DRIFT_DISTANCE: f32 = 48.0;
pub const GRAB_BIAS_VIEW_CLEARANCE: f32 = 4.0;
pub fn grab_bias_full_view() -> f32 {
-(2.0 * HANDLE_RADIUS + GRAB_BIAS_VIEW_CLEARANCE)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HandleGrabOffset {
initial_bias: f32,
bias: f32,
start_y: f32,
furthest_y: f32,
drift_progress: f32,
drifts: bool,
}
impl HandleGrabOffset {
pub fn begin(handle_tip_y: f32, finger_y: f32) -> Self {
Self::begin_for(handle_tip_y, finger_y, true)
}
pub fn begin_for(handle_tip_y: f32, finger_y: f32, drifts: bool) -> Self {
let initial_bias = handle_tip_y - finger_y;
Self {
initial_bias,
bias: initial_bias,
start_y: finger_y,
furthest_y: finger_y,
drift_progress: 0.0,
drifts,
}
}
pub fn track(&mut self, finger_y: f32) -> f32 {
if !self.drifts {
self.bias = self.initial_bias;
return self.bias;
}
self.furthest_y = self.furthest_y.max(finger_y);
let travel = (self.furthest_y - self.start_y - GRAB_DIRECT_FOLLOW_DISTANCE).max(0.0);
let t = (travel / GRAB_VISIBILITY_DRIFT_DISTANCE).clamp(0.0, 1.0);
self.drift_progress = t * t * (3.0 - 2.0 * t);
let full_view = self.initial_bias.min(grab_bias_full_view());
self.bias = self.initial_bias + (full_view - self.initial_bias) * self.drift_progress;
self.bias
}
pub fn bias(&self) -> f32 {
self.bias
}
pub fn drift_progress(&self) -> f32 {
self.drift_progress
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum HandleKind {
Cursor,
SelectionStart,
SelectionEnd,
}
pub const HANDLE_RADIUS: f32 = 8.0;
pub const HANDLE_STEM_WIDTH: f32 = 2.0;
pub const HANDLE_DOT_LINE_OVERLAP: f32 = 2.0;
pub fn handle_path_data(
kind: HandleKind,
anchor_x: f32,
line_top: f32,
line_bottom: f32,
radius: f32,
) -> String {
let r = radius.max(0.0);
let half_stem = HANDLE_STEM_WIDTH * 0.5;
let (left, right) = (anchor_x - half_stem, anchor_x + half_stem);
let stem = |top: f32, bottom: f32| {
format!("M {left} {top} L {right} {top} L {right} {bottom} L {left} {bottom} Z")
};
let dot = |cy: f32| {
format!(
"M {x0} {cy} A {r} {r} 0 1 1 {x1} {cy} A {r} {r} 0 1 1 {x0} {cy} Z",
x0 = anchor_x - r,
x1 = anchor_x + r,
)
};
match kind {
HandleKind::SelectionStart => {
let cy = line_top - r + HANDLE_DOT_LINE_OVERLAP;
format!("{} {}", stem(line_top, line_bottom), dot(cy))
}
HandleKind::SelectionEnd | HandleKind::Cursor => {
let cy = line_bottom + r - HANDLE_DOT_LINE_OVERLAP;
format!("{} {}", stem(line_top, line_bottom), dot(cy))
}
}
}
pub const HANDLE_GRAB_SLOP: f32 = 24.0;
pub fn selection_after_handle_drag(
dragged: HandleKind,
fixed_edge: usize,
dragged_offset: usize,
text_len: usize,
) -> (usize, usize) {
let fixed = fixed_edge.min(text_len);
let dragged_offset = dragged_offset.min(text_len);
match dragged {
HandleKind::SelectionStart => {
let start = dragged_offset.min(fixed.saturating_sub(1));
(start, fixed)
}
HandleKind::SelectionEnd => {
let end = dragged_offset.max(fixed + 1).min(text_len);
(fixed, end)
}
HandleKind::Cursor => (dragged_offset, dragged_offset),
}
}
#[cfg(test)]
#[path = "tests/text_selection_tests.rs"]
mod tests;