pub mod affordance_layer;
pub mod magnifier;
use std::cell::RefCell;
use std::ops::Range;
use std::rc::Rc;
use teksilo_canvas::{Point, Rect};
use teksilo_tokens::{InputTokens, TargetRole};
use crate::environment::LayoutDirection;
use crate::event::{EventResponse, WidgetEvent};
use crate::overlay::direction::HorizontalSide;
use crate::overlay::{OverlayPlacement, SelectionHandleKind};
use crate::signal::Signal;
use crate::styles::TextSelectionHandleRecipe;
use crate::styles::density::dp;
use crate::widget::EventContext;
pub use affordance_layer::{
SelectionHandle, TextAffordanceDelegate, TextAffordanceLayer, TextMagnifier,
};
pub use magnifier::MagnifierRequest;
pub const HANDLE_DIAMETER: f32 = 24.0;
pub const HANDLE_HIT_SIZE: f32 = 44.0;
pub const HANDLE_STEM_WIDTH: f32 = 2.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TextAction {
Cut,
Copy,
Paste,
SelectAll,
Custom(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ClipboardActions {
pub cut: bool,
pub copy: bool,
pub paste: bool,
pub select_all: bool,
}
impl ClipboardActions {
pub fn to_actions(self) -> Vec<TextAction> {
let mut actions = Vec::new();
if self.cut {
actions.push(TextAction::Cut);
}
if self.copy {
actions.push(TextAction::Copy);
}
if self.paste {
actions.push(TextAction::Paste);
}
if self.select_all {
actions.push(TextAction::SelectAll);
}
actions
}
}
pub trait TextHitSource {
fn offset_at(&self, point: Point) -> usize;
fn caret_rect(&self, offset: usize) -> Rect;
fn word_range_at(&self, offset: usize) -> Range<usize>;
fn line_range_at(&self, offset: usize) -> Range<usize>;
fn selection(&self) -> Range<usize>;
fn set_selection(&mut self, range: Range<usize>);
fn selection_bounds(&self) -> Option<Rect>;
fn viewport(&self) -> Rect;
fn document_len(&self) -> usize;
fn is_editable(&self) -> bool;
fn allows_copy(&self) -> bool {
true
}
fn clipboard_actions(&self) -> ClipboardActions {
let editable = self.is_editable();
let has_selection = !self.selection().is_empty();
ClipboardActions {
cut: editable && has_selection && self.allows_copy(),
copy: has_selection && self.allows_copy(),
paste: editable,
select_all: !has_selection,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SelectionHandleGeometry {
pub kind: SelectionHandleKind,
pub offset: usize,
pub document_len: usize,
pub side: Option<HorizontalSide>,
pub caret: Rect,
pub anchor: Point,
pub visual: Rect,
pub hit: Rect,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HandleMetrics {
pub diameter: f32,
pub hit: f32,
}
impl HandleMetrics {
pub fn for_tokens(tokens: &InputTokens) -> Self {
Self {
diameter: dp(HANDLE_DIAMETER, TargetRole::Decoration, tokens),
hit: dp(HANDLE_HIT_SIZE, TargetRole::Target, tokens),
}
}
}
impl Default for HandleMetrics {
fn default() -> Self {
Self::for_tokens(&InputTokens::default())
}
}
impl From<&TextSelectionHandleRecipe> for HandleMetrics {
fn from(recipe: &TextSelectionHandleRecipe) -> Self {
Self {
diameter: recipe.diameter,
hit: recipe.hit_size,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SelectionToolbarRequest {
pub actions: Vec<TextAction>,
pub anchor: Rect,
}
impl SelectionToolbarRequest {
pub fn placement(&self) -> OverlayPlacement {
OverlayPlacement::AboveSelection {
selection: self.anchor,
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
struct AffordanceState {
handles: Vec<SelectionHandleGeometry>,
magnifier: Option<MagnifierRequest>,
toolbar: Option<SelectionToolbarRequest>,
}
#[derive(Clone)]
pub struct TextAffordances {
inner: Rc<RefCell<AffordanceState>>,
version: Signal<u64>,
}
impl Default for TextAffordances {
fn default() -> Self {
Self {
inner: Rc::new(RefCell::new(AffordanceState::default())),
version: Signal::new(0),
}
}
}
impl std::fmt::Debug for TextAffordances {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let state = self.inner.borrow();
f.debug_struct("TextAffordances")
.field("handles", &state.handles.len())
.field("magnifier", &state.magnifier.is_some())
.field("toolbar", &state.toolbar.is_some())
.finish()
}
}
impl TextAffordances {
pub fn new() -> Self {
Self::default()
}
pub fn version_signal(&self) -> Signal<u64> {
self.version.clone()
}
pub fn handles(&self) -> Vec<SelectionHandleGeometry> {
self.inner.borrow().handles.clone()
}
pub fn handle(&self, kind: SelectionHandleKind) -> Option<SelectionHandleGeometry> {
self.inner
.borrow()
.handles
.iter()
.find(|h| h.kind == kind)
.copied()
}
pub fn handle_visible_signal(&self, kind: SelectionHandleKind) -> Signal<bool> {
let inner = Rc::clone(&self.inner);
self.version
.map(move |_| inner.borrow().handles.iter().any(|h| h.kind == kind))
}
pub fn magnifier(&self) -> Option<MagnifierRequest> {
self.inner.borrow().magnifier
}
pub fn magnifier_visible_signal(&self) -> Signal<bool> {
let inner = Rc::clone(&self.inner);
self.version
.map(move |_| inner.borrow().magnifier.is_some())
}
pub fn toolbar(&self) -> Option<SelectionToolbarRequest> {
self.inner.borrow().toolbar.clone()
}
pub fn is_empty(&self) -> bool {
let state = self.inner.borrow();
state.handles.is_empty() && state.magnifier.is_none() && state.toolbar.is_none()
}
fn publish(&self, next: AffordanceState) {
{
let mut state = self.inner.borrow_mut();
if *state == next {
return;
}
*state = next;
}
let version = self.version.get();
self.version.set(version.wrapping_add(1));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandleDragPhase {
Begin,
Move,
End,
Cancel,
}
pub fn drag_moves_the_caret(kind: SelectionHandleKind, phase: HandleDragPhase) -> bool {
kind == SelectionHandleKind::Caret
&& matches!(
phase,
HandleDragPhase::Begin | HandleDragPhase::Move | HandleDragPhase::End
)
}
#[derive(Debug, Clone, Copy)]
struct HandleDrag {
kind: SelectionHandleKind,
fixed: usize,
}
pub struct TouchSelection {
metrics: HandleMetrics,
magnifier_radius: f32,
magnifier_half_height: f32,
magnifier_rise: f32,
magnifier_scale: f32,
magnifier_enabled: bool,
reduced_motion: bool,
affordances: TextAffordances,
drag: Option<HandleDrag>,
raised: bool,
}
impl std::fmt::Debug for TouchSelection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TouchSelection")
.field("metrics", &self.metrics)
.field("magnifier_enabled", &self.magnifier_enabled)
.field("reduced_motion", &self.reduced_motion)
.field("dragging", &self.drag.is_some())
.field("raised", &self.raised)
.finish()
}
}
impl Default for TouchSelection {
fn default() -> Self {
Self::new()
}
}
impl TouchSelection {
pub fn new() -> Self {
Self {
metrics: HandleMetrics::default(),
magnifier_radius: magnifier::MAGNIFIER_RADIUS,
magnifier_half_height: magnifier::MAGNIFIER_HALF_HEIGHT,
magnifier_rise: magnifier::MAGNIFIER_RISE,
magnifier_scale: magnifier::MAGNIFIER_SCALE,
magnifier_enabled: true,
reduced_motion: false,
affordances: TextAffordances::new(),
drag: None,
raised: false,
}
}
pub fn metrics(mut self, metrics: HandleMetrics) -> Self {
self.metrics = metrics;
self
}
pub fn magnifier_metrics(
mut self,
radius: f32,
half_height: f32,
rise: f32,
scale: f32,
) -> Self {
self.magnifier_radius = radius;
self.magnifier_half_height = half_height;
self.magnifier_rise = rise;
self.magnifier_scale = scale;
self
}
pub fn magnifier(mut self, enabled: bool) -> Self {
self.magnifier_enabled = enabled;
self
}
pub fn reduced_motion(mut self, reduced: bool) -> Self {
self.reduced_motion = reduced;
self
}
pub fn affordances(&self) -> TextAffordances {
self.affordances.clone()
}
pub fn handles(&self) -> Vec<SelectionHandleGeometry> {
self.affordances.handles()
}
pub fn magnifier_request(&self) -> Option<MagnifierRequest> {
self.affordances.magnifier()
}
pub fn toolbar(&self) -> Option<SelectionToolbarRequest> {
self.affordances.toolbar()
}
pub fn is_dragging(&self) -> bool {
self.drag.is_some()
}
pub fn dismiss(&mut self) {
self.drag = None;
self.raised = false;
self.affordances.publish(AffordanceState::default());
}
pub fn refresh(&mut self, direction: LayoutDirection, source: &dyn TextHitSource) {
if !self.raised {
return;
}
self.affordances.publish(AffordanceState {
handles: self.compute_handles(direction, source),
toolbar: self.compute_toolbar(source),
magnifier: None,
});
}
pub fn raise(&mut self, direction: LayoutDirection, source: &dyn TextHitSource) {
self.raised = true;
self.refresh(direction, source);
}
pub fn on_long_press(
&mut self,
pointer: crate::pointer::PointerInfo,
point: Point,
ctx: &mut EventContext<'_>,
source: &mut dyn TextHitSource,
) -> EventResponse {
if !pointer.kind.is_direct() {
return EventResponse::Ignored;
}
let offset = source.offset_at(point);
let word = source.word_range_at(offset);
source.set_selection(word);
self.raised = true;
self.refresh(ctx.layout_direction(), source);
EventResponse::Handled
}
pub fn handle_pointer(
&mut self,
event: &WidgetEvent,
ctx: &mut EventContext<'_>,
source: &mut dyn TextHitSource,
) -> EventResponse {
if !ctx.pointer_kind().is_direct() {
return EventResponse::Ignored;
}
let direction = ctx.layout_direction();
match event {
WidgetEvent::PointerDown { position, .. } => {
match self.handle_at(*position) {
Some(kind) => {
self.begin_drag(kind, *position, direction, source);
ctx.capture_pointer();
EventResponse::Handled
}
None => EventResponse::Ignored,
}
}
WidgetEvent::PointerMove { position, .. } if self.drag.is_some() => {
self.update_drag(*position, direction, source);
EventResponse::Handled
}
WidgetEvent::PointerUp { position, .. } => {
if self.drag.is_some() {
self.end_drag(*position, direction, source);
EventResponse::Handled
} else {
self.raise(direction, source);
EventResponse::Ignored
}
}
WidgetEvent::PointerCancel { .. } if self.drag.is_some() => {
self.cancel_drag(direction, source);
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
}
pub fn drag_handle(
&mut self,
kind: SelectionHandleKind,
phase: HandleDragPhase,
point: Point,
ctx: &mut EventContext<'_>,
source: &mut dyn TextHitSource,
) -> EventResponse {
if !ctx.pointer_kind().is_direct() {
return EventResponse::Ignored;
}
let direction = ctx.layout_direction();
match phase {
HandleDragPhase::Begin => self.begin_drag(kind, point, direction, source),
HandleDragPhase::Move => self.update_drag(point, direction, source),
HandleDragPhase::End => self.end_drag(point, direction, source),
HandleDragPhase::Cancel => self.cancel_drag(direction, source),
}
EventResponse::Handled
}
pub fn handle_at(&self, point: Point) -> Option<SelectionHandleKind> {
self.affordances
.handles()
.into_iter()
.filter(|h| h.hit.contains(point))
.min_by(|a, b| {
distance_squared(a.anchor, point).total_cmp(&distance_squared(b.anchor, point))
})
.map(|h| h.kind)
}
fn begin_drag(
&mut self,
kind: SelectionHandleKind,
point: Point,
direction: LayoutDirection,
source: &mut dyn TextHitSource,
) {
let selection = source.selection();
let fixed = match kind {
SelectionHandleKind::Start => selection.end,
SelectionHandleKind::End => selection.start,
SelectionHandleKind::Caret => selection.start,
};
self.drag = Some(HandleDrag { kind, fixed });
self.raised = true;
self.update_drag(point, direction, source);
}
fn update_drag(
&mut self,
point: Point,
direction: LayoutDirection,
source: &mut dyn TextHitSource,
) {
let Some(drag) = self.drag else {
return;
};
let moving = source.offset_at(point);
let dragging_caret = drag.kind == SelectionHandleKind::Caret;
if dragging_caret {
source.set_selection(moving..moving);
} else {
source.set_selection(drag.fixed.min(moving)..drag.fixed.max(moving));
}
let mut handles = self.compute_handles(direction, source);
if !dragging_caret {
handles.retain(|h| h.kind != SelectionHandleKind::Caret);
}
self.affordances.publish(AffordanceState {
handles,
magnifier: self.compute_magnifier(point, source),
toolbar: None,
});
}
fn end_drag(
&mut self,
point: Point,
direction: LayoutDirection,
source: &mut dyn TextHitSource,
) {
if self.drag.is_some() {
self.update_drag(point, direction, source);
}
self.drag = None;
self.refresh(direction, source);
}
fn cancel_drag(&mut self, direction: LayoutDirection, source: &mut dyn TextHitSource) {
self.drag = None;
self.refresh(direction, source);
}
fn compute_handles(
&self,
direction: LayoutDirection,
source: &dyn TextHitSource,
) -> Vec<SelectionHandleGeometry> {
let viewport = source.viewport();
let document_len = source.document_len();
let selection = source.selection();
let kinds: &[(SelectionHandleKind, usize)] = &if selection.is_empty() {
if source.is_editable() {
vec![(SelectionHandleKind::Caret, selection.start)]
} else {
vec![]
}
} else {
vec![
(SelectionHandleKind::Start, selection.start),
(SelectionHandleKind::End, selection.end),
]
};
kinds
.iter()
.filter_map(|&(kind, offset)| {
handle_geometry(
kind,
offset,
document_len,
source.caret_rect(offset),
direction,
viewport,
self.metrics,
)
})
.collect()
}
fn compute_toolbar(&self, source: &dyn TextHitSource) -> Option<SelectionToolbarRequest> {
let actions = source.clipboard_actions().to_actions();
if actions.is_empty() {
return None;
}
let anchor = source
.selection_bounds()
.unwrap_or_else(|| source.caret_rect(source.selection().end));
Some(SelectionToolbarRequest { actions, anchor })
}
fn compute_magnifier(
&self,
point: Point,
source: &dyn TextHitSource,
) -> Option<MagnifierRequest> {
if !self.magnifier_enabled || self.reduced_motion {
return None;
}
Some(MagnifierRequest::new(
point,
source.viewport(),
self.magnifier_radius,
self.magnifier_half_height,
self.magnifier_rise,
self.magnifier_scale,
))
}
}
fn distance_squared(a: Point, b: Point) -> f32 {
let dx = a.x - b.x;
let dy = a.y - b.y;
dx * dx + dy * dy
}
pub fn handle_geometry(
kind: SelectionHandleKind,
offset: usize,
document_len: usize,
caret: Rect,
direction: LayoutDirection,
viewport: Rect,
metrics: HandleMetrics,
) -> Option<SelectionHandleGeometry> {
if !rects_intersect(caret, viewport) {
return None;
}
let radius = metrics.diameter / 2.0;
let cx = caret.x + caret.width / 2.0;
let above = Point::new(cx, caret.y - radius);
let below = Point::new(cx, caret.bottom() + radius);
let (preferred, alternate) = match kind {
SelectionHandleKind::Start => (above, below),
SelectionHandleKind::End | SelectionHandleKind::Caret => (below, above),
};
let fits = |p: Point| p.y - radius >= viewport.y && p.y + radius <= viewport.bottom();
let anchor = if fits(preferred) {
preferred
} else if fits(alternate) {
alternate
} else {
preferred
};
let visual = square_centred_on(anchor, metrics.diameter);
let slack = ((metrics.hit - metrics.diameter) / 2.0).max(0.0);
let hit = nudge_into(square_centred_on(anchor, metrics.hit), viewport, slack);
Some(SelectionHandleGeometry {
kind,
offset,
document_len,
side: kind.side(direction),
caret,
anchor,
visual,
hit,
})
}
fn square_centred_on(centre: Point, extent: f32) -> Rect {
Rect::new(
centre.x - extent / 2.0,
centre.y - extent / 2.0,
extent,
extent,
)
}
fn nudge_into(rect: Rect, bounds: Rect, slack: f32) -> Rect {
let dx = if rect.x < bounds.x {
(bounds.x - rect.x).min(slack)
} else if rect.right() > bounds.right() {
-((rect.right() - bounds.right()).min(slack))
} else {
0.0
};
let dy = if rect.y < bounds.y {
(bounds.y - rect.y).min(slack)
} else if rect.bottom() > bounds.bottom() {
-((rect.bottom() - bounds.bottom()).min(slack))
} else {
0.0
};
Rect::new(rect.x + dx, rect.y + dy, rect.width, rect.height)
}
fn rects_intersect(a: Rect, b: Rect) -> bool {
a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
}
#[cfg(test)]
mod tests;