use std::cell::Cell;
use std::rc::Rc;
use teksilo_canvas::{Point, Rect};
use teksilo_core::event::{EventResponse, PointerButton, ScrollDelta, WidgetEvent};
use teksilo_core::widget::{CursorIcon, EventContext};
use teksilo_text::text_document::{MoveMode, SelectionType};
use super::hit_test;
use super::state::{DragState, SharedState};
use super::sync_cursor_signals;
fn to_engine_local(state: &SharedState, position: &Point) -> Point {
let st = state.borrow();
Point::new(
position.x + st.node_origin.x - st.viewport_origin.x,
position.y + st.node_origin.y - st.viewport_origin.y,
)
}
const RESIZE_MIN_EDGE: f32 = 24.0;
const RESIZE_HANDLE_SLOP: f32 = 5.0;
fn grabbed_handle(
state: &SharedState,
local: Point,
) -> Option<(String, [f32; 4], usize, (f32, f32))> {
let selected = state.borrow().selected_image.borrow().clone()?;
let corner = handle_at(selected.rect, local)?;
Some((selected.name, selected.rect, selected.offset, corner))
}
fn handle_at(rect: [f32; 4], local: Point) -> Option<(f32, f32)> {
let [x, y, w, h] = rect;
let reach = super::paint::RESIZE_HANDLE_SIZE / 2.0 + RESIZE_HANDLE_SLOP;
super::paint::RESIZE_CORNERS.into_iter().find(|&(fx, fy)| {
let (cx, cy) = (x + w * fx, y + h * fy);
(local.x - cx).abs() <= reach && (local.y - cy).abs() <= reach
})
}
fn proportional_resize(origin: [f32; 4], corner: (f32, f32), local: Point) -> [f32; 4] {
let [x, y, w, h] = origin;
if w <= 0.0 || h <= 0.0 {
return origin;
}
let anchor_x = x + w * (1.0 - corner.0);
let anchor_y = y + h * (1.0 - corner.1);
let dragged_w = (local.x - anchor_x).abs();
let dragged_h = (local.y - anchor_y).abs();
let sx = dragged_w / w;
let sy = dragged_h / h;
let scale = if (sx - 1.0).abs() >= (sy - 1.0).abs() {
sx
} else {
sy
};
let scale = scale.max(RESIZE_MIN_EDGE / w.min(h));
[x, y, (w * scale).max(1.0), (h * scale).max(1.0)]
}
const TEXT_DRAG_THRESHOLD: f32 = 4.0;
fn selection_contains(state: &SharedState, offset: usize) -> bool {
let st = state.borrow();
if !st.cursor.has_selection() {
return false;
}
let (a, b) = (st.cursor.anchor(), st.cursor.position());
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
(lo..hi).contains(&offset)
}
fn start_text_drag(state: &SharedState, ctx: &mut EventContext) {
let payload = {
let st = state.borrow();
let Some(source) = st.self_id else {
return;
};
if !st.cursor.has_selection() {
return;
}
let (a, b) = (st.cursor.anchor(), st.cursor.position());
let range = if a <= b { (a, b) } else { (b, a) };
let fragment = st.cursor.selection();
let text = fragment.to_plain_text().to_string();
if text.is_empty() {
return;
}
teksilo_core::DragPayload::typed(super::EditorTextDrag {
source,
range,
fragment,
text: text.clone(),
})
.with_mime("text/plain", text.into_bytes())
};
let source = state.borrow().self_id;
if let Some(source) = source {
ctx.start_drag(source, payload);
}
state.borrow_mut().drag_state = DragState::Idle;
ctx.request_frame();
}
pub(super) fn apply_text_drop(
state: &SharedState,
drag: &super::EditorTextDrag,
same_editor: bool,
) -> bool {
let (lo, hi) = drag.range;
let drop_at = state.borrow().cursor.position();
if !same_editor {
let st = state.borrow();
super::keyboard::collapse_selection_before_insert(&st);
let _ = st.cursor.insert_fragment(&drag.fragment);
return true;
}
if !state
.borrow()
.policy
.command_filter
.accepts(super::policy::EditCommandKind::Cut)
{
return false;
}
if (lo..=hi).contains(&drop_at) {
return true;
}
{
let st = state.borrow();
st.cursor.set_position(lo, MoveMode::MoveAnchor);
st.cursor.set_position(hi, MoveMode::KeepAnchor);
let _ = st.cursor.remove_selected_text();
let target = if drop_at > hi {
drop_at - (hi - lo)
} else {
drop_at
};
st.cursor.set_position(target, MoveMode::MoveAnchor);
let _ = st.cursor.insert_fragment(&drag.fragment);
}
true
}
pub(super) fn handle_pointer_event(
state: &SharedState,
v_scrollbar_bounds: &Rc<Cell<Rect>>,
h_scrollbar_bounds: &Rc<Cell<Rect>>,
event: &WidgetEvent,
ctx: &mut EventContext,
) -> EventResponse {
match event {
WidgetEvent::PointerDown {
position,
button,
modifiers,
} => {
if *button != PointerButton::Primary {
return EventResponse::Ignored;
}
if v_scrollbar_bounds.get().contains(*position)
|| h_scrollbar_bounds.get().contains(*position)
{
return EventResponse::Ignored;
}
let shift = modifiers.shift();
let local = to_engine_local(state, position);
if let Some((name, rect, offset, corner)) = grabbed_handle(state, local) {
let mut st = state.borrow_mut();
st.drag_state = DragState::ResizingImage {
name,
offset,
origin: rect,
corner,
};
st.resize_preview.set(Some(rect));
drop(st);
ctx.request_frame();
return EventResponse::Ignored;
}
let hit = {
let st = state.borrow();
hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
};
let Some(hit) = hit else {
return EventResponse::Ignored;
};
let read_only = state.borrow().policy.is_read_only();
match &hit.region {
teksilo_text::HitRegion::Link { href } if modifiers.command() || read_only => {
let callback = state.borrow().on_link_activated.clone();
if let Some(cb) = callback {
cb(href.as_str(), ctx);
}
ctx.request_frame();
return EventResponse::Ignored;
}
teksilo_text::HitRegion::Image { name } => {
let callback = state.borrow().on_image_activated.clone();
if let Some(cb) = callback {
cb(
&super::ImageActivation {
name: name.clone(),
offset: hit.position,
},
ctx,
);
}
{
let mut st = state.borrow_mut();
st.drag_state = DragState::Selecting {
auto_scroll_v_per_s: 0.0,
};
st.preferred_x = None;
st.select_all_level = 0;
st.select_all_anchor_cell = None;
st.mouse_anchored = true;
}
ctx.request_frame();
return EventResponse::Ignored;
}
_ => {}
}
if !shift && selection_contains(state, hit.position) {
let mut st = state.borrow_mut();
st.drag_state = DragState::PendingTextDrag {
origin: [position.x, position.y],
};
st.mouse_anchored = true;
drop(st);
return EventResponse::Ignored;
}
{
let mut st = state.borrow_mut();
let mode = if shift {
MoveMode::KeepAnchor
} else {
MoveMode::MoveAnchor
};
st.cursor.set_position(hit.position, mode);
st.cursor_affinity = hit.affinity;
st.drag_state = DragState::Selecting {
auto_scroll_v_per_s: 0.0,
};
st.preferred_x = None;
st.select_all_level = 0;
st.select_all_anchor_cell = None;
st.mouse_anchored = true;
}
sync_cursor_signals(state);
super::keyboard::chase_caret_into_view(state, ctx);
ctx.request_frame();
EventResponse::Ignored
}
WidgetEvent::PointerMove { position } => {
let (is_dragging, resizing, viewport_height) = {
let st = state.borrow();
let dragging = matches!(st.drag_state, DragState::Selecting { .. });
let resizing = match &st.drag_state {
DragState::ResizingImage { origin, corner, .. } => Some((*origin, *corner)),
_ => None,
};
(dragging, resizing, st.viewport_height)
};
let pending = match &state.borrow().drag_state {
DragState::PendingTextDrag { origin } => Some(*origin),
_ => None,
};
if let Some(origin) = pending {
let dx = position.x - origin[0];
let dy = position.y - origin[1];
if dx * dx + dy * dy < TEXT_DRAG_THRESHOLD * TEXT_DRAG_THRESHOLD {
return EventResponse::Handled;
}
start_text_drag(state, ctx);
return EventResponse::Handled;
}
if let Some((origin, corner)) = resizing {
let local = to_engine_local(state, position);
let proposed = proportional_resize(origin, corner, local);
state.borrow().resize_preview.set(Some(proposed));
ctx.request_frame();
return EventResponse::Handled;
}
if !is_dragging {
if state.borrow().policy.is_read_only() {
let local = to_engine_local(state, position);
let over_link = {
let st = state.borrow();
hit_test::hit_test_at(&st.engine, local, 0.0, 0.0).is_some_and(|hit| {
matches!(hit.region, teksilo_text::HitRegion::Link { .. })
})
};
if over_link {
ctx.set_cursor(CursorIcon::Pointer);
}
}
return EventResponse::Ignored;
}
let local = to_engine_local(state, position);
let clamped = Point::new(
local.x,
local.y.clamp(2.0, (viewport_height - 2.0).max(2.0)),
);
let hit = {
let st = state.borrow();
hit_test::hit_test_at(&st.engine, clamped, 0.0, 0.0)
};
if let Some(hit) = hit {
{
let mut st = state.borrow_mut();
st.cursor.set_position(hit.position, MoveMode::KeepAnchor);
st.cursor_affinity = hit.affinity;
}
sync_cursor_signals(state);
}
{
let mut st = state.borrow_mut();
let v = if local.y < 20.0 {
let intensity = ((20.0 - local.y) / 20.0).clamp(0.0, 1.0);
-60.0 * 60.0 * intensity
} else if local.y > viewport_height - 20.0 {
let intensity = ((local.y - (viewport_height - 20.0)) / 20.0).clamp(0.0, 1.0);
60.0 * 60.0 * intensity
} else {
0.0
};
st.drag_state = DragState::Selecting {
auto_scroll_v_per_s: v,
};
if v != 0.0 {
if let Some(handle) = &st.frame_request {
handle.set(true);
}
}
}
ctx.request_frame();
EventResponse::Handled
}
WidgetEvent::PointerUp { position, .. } => {
if matches!(state.borrow().drag_state, DragState::PendingTextDrag { .. }) {
let local = to_engine_local(state, position);
let hit = {
let st = state.borrow();
hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
};
{
let mut st = state.borrow_mut();
st.drag_state = DragState::Idle;
if let Some(hit) = hit {
st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
st.cursor_affinity = hit.affinity;
st.preferred_x = None;
st.select_all_level = 0;
st.select_all_anchor_cell = None;
}
}
sync_cursor_signals(state);
ctx.request_frame();
return EventResponse::Ignored;
}
let finished = {
let st = state.borrow();
match (&st.drag_state, st.resize_preview.get()) {
(DragState::ResizingImage { name, offset, .. }, Some(rect)) => {
Some((st.on_image_resized.clone(), name.clone(), *offset, rect))
}
_ => None,
}
};
{
let mut st = state.borrow_mut();
st.drag_state = DragState::Idle;
st.resize_preview.set(None);
}
if let Some((callback, name, offset, rect)) = finished {
if let Some(cb) = callback {
cb(
&super::ImageResize {
name,
offset,
width: rect[2].round().max(1.0) as u32,
height: rect[3].round().max(1.0) as u32,
},
ctx,
);
}
ctx.request_frame();
return EventResponse::Handled;
}
EventResponse::Ignored
}
_ => EventResponse::Ignored,
}
}
pub(super) fn handle_scroll(
state: &SharedState,
overscroll: crate::common::scroll::OverscrollBehavior,
event: &WidgetEvent,
ctx: &mut EventContext,
) -> EventResponse {
let WidgetEvent::Scroll { delta, .. } = event else {
return EventResponse::Ignored;
};
let (dx, dy) = match delta {
ScrollDelta::Lines { x, y } => (*x * 16.0, *y * 16.0),
ScrollDelta::Pixels { x, y } => (*x, *y),
};
let st = state.borrow();
let (new_x, moved_x) =
crate::common::scroll::scroll_clamp_axis(st.scroll_x.get(), dx, st.max_scroll_x.get());
let (new_y, moved_y) =
crate::common::scroll::scroll_clamp_axis(st.scroll_y.get(), dy, st.max_scroll_y.get());
if moved_x {
st.scroll_x.set(new_x);
}
if moved_y {
st.scroll_y.set(new_y);
}
drop(st);
if moved_x || moved_y {
ctx.request_frame();
}
crate::common::scroll::scroll_response(
moved_x || moved_y,
overscroll == crate::common::scroll::OverscrollBehavior::Contain,
)
}
pub(super) fn handle_double_tap(state: &SharedState, pos: Point, ctx: &mut EventContext) {
tap_select(state, pos, SelectionType::WordUnderCursor);
super::keyboard::chase_caret_into_view(state, ctx);
ctx.request_frame();
}
pub(super) fn handle_triple_tap(state: &SharedState, pos: Point, ctx: &mut EventContext) {
tap_select(state, pos, SelectionType::BlockUnderCursor);
super::keyboard::chase_caret_into_view(state, ctx);
ctx.request_frame();
}
fn tap_select(state: &SharedState, pos: Point, kind: SelectionType) {
state.borrow_mut().mouse_anchored = true;
let local = to_engine_local(state, &pos);
let hit = {
let st = state.borrow();
hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
};
if let Some(hit) = hit {
let st = state.borrow();
st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
st.cursor.select(kind);
drop(st);
sync_cursor_signals(state);
}
}
fn engine_local_of_window(state: &SharedState, window_position: Point) -> Point {
let st = state.borrow();
Point::new(
window_position.x - st.viewport_origin.x,
window_position.y - st.viewport_origin.y,
)
}
pub(super) fn reposition_caret_for_context_menu(state: &SharedState, window_position: Point) {
let local = engine_local_of_window(state, window_position);
let hit = {
let st = state.borrow();
hit_test::hit_test_at(&st.engine, local, 0.0, 0.0)
};
let Some(hit) = hit else {
return;
};
{
let st = state.borrow();
if st.cursor.has_selection() {
let (lo, hi) = (st.cursor.selection_start(), st.cursor.selection_end());
if hit.position >= lo && hit.position <= hi {
return;
}
}
}
{
let mut st = state.borrow_mut();
st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
st.cursor_affinity = hit.affinity;
st.preferred_x = None;
st.select_all_level = 0;
st.select_all_anchor_cell = None;
}
sync_cursor_signals(state);
}
pub(super) fn move_caret_for_drag(state: &SharedState, local: Point) -> bool {
let hit = {
let st = state.borrow();
hit_test::hit_test_at(&st.engine, to_engine_local(state, &local), 0.0, 0.0)
};
let Some(hit) = hit else { return false };
{
let mut st = state.borrow_mut();
st.cursor.set_position(hit.position, MoveMode::MoveAnchor);
st.cursor_affinity = hit.affinity;
st.drop_caret = true;
}
sync_cursor_signals(state);
true
}
pub(super) fn clear_drop_caret(state: &SharedState) {
state.borrow_mut().drop_caret = false;
}
pub(super) fn offset_at_window_point(state: &SharedState, window_position: Point) -> Option<usize> {
let local = engine_local_of_window(state, window_position);
let st = state.borrow();
hit_test::hit_test_at(&st.engine, local, 0.0, 0.0).map(|hit| hit.position)
}
#[cfg(test)]
mod resize_tests {
use super::*;
const IMG: [f32; 4] = [50.0, 20.0, 200.0, 100.0];
const BOTTOM_RIGHT: (f32, f32) = (1.0, 1.0);
const TOP_LEFT: (f32, f32) = (0.0, 0.0);
fn ratio(rect: [f32; 4]) -> f32 {
rect[2] / rect[3]
}
#[test]
fn dragging_a_corner_keeps_the_proportions() {
for target in [
Point::new(450.0, 220.0),
Point::new(450.0, 130.0),
Point::new(260.0, 320.0),
] {
let out = proportional_resize(IMG, BOTTOM_RIGHT, target);
assert!(
(ratio(out) - ratio(IMG)).abs() < 0.001,
"proportions drifted: {out:?} is {:.3}, wanted {:.3}",
ratio(out),
ratio(IMG)
);
}
}
#[test]
fn dragging_outward_grows_and_inward_shrinks() {
let bigger = proportional_resize(IMG, BOTTOM_RIGHT, Point::new(450.0, 220.0));
assert!(bigger[2] > IMG[2], "{bigger:?}");
let smaller = proportional_resize(IMG, BOTTOM_RIGHT, Point::new(150.0, 70.0));
assert!(smaller[2] < IMG[2], "{smaller:?}");
}
#[test]
fn the_opposite_corner_is_what_stays_put() {
let out = proportional_resize(IMG, TOP_LEFT, Point::new(0.0, 0.0));
assert!(
out[2] > IMG[2],
"dragging the top-left up and out must grow: {out:?}"
);
}
#[test]
fn a_resize_cannot_shrink_the_image_out_of_reach() {
let out = proportional_resize(IMG, BOTTOM_RIGHT, Point::new(51.0, 21.0));
assert!(out[2] >= RESIZE_MIN_EDGE, "{out:?}");
assert!(out[3] >= RESIZE_MIN_EDGE, "{out:?}");
assert!(
(ratio(out) - ratio(IMG)).abs() < 0.001,
"the floor must not distort it: {out:?}"
);
}
#[test]
fn the_preview_stays_at_the_images_own_place_in_the_text() {
for corner in [TOP_LEFT, BOTTOM_RIGHT, (1.0, 0.0), (0.0, 1.0)] {
let out = proportional_resize(IMG, corner, Point::new(400.0, 300.0));
assert_eq!((out[0], out[1]), (IMG[0], IMG[1]), "{corner:?} moved it");
}
}
#[test]
fn a_degenerate_image_is_left_alone() {
let zero = [10.0, 10.0, 0.0, 0.0];
assert_eq!(
proportional_resize(zero, BOTTOM_RIGHT, Point::new(99.0, 99.0)),
zero
);
}
#[test]
fn each_corner_is_grabbable_from_either_side_of_its_edge() {
for (fx, fy) in super::super::paint::RESIZE_CORNERS {
let (cx, cy) = (IMG[0] + IMG[2] * fx, IMG[1] + IMG[3] * fy);
for (dx, dy) in [(0.0, 0.0), (-4.0, -4.0), (4.0, 4.0)] {
assert_eq!(
handle_at(IMG, Point::new(cx + dx, cy + dy)),
Some((fx, fy)),
"corner {fx},{fy} missed at offset {dx},{dy}"
);
}
}
}
#[test]
fn the_middle_of_the_picture_grabs_nothing() {
let centre = Point::new(IMG[0] + IMG[2] / 2.0, IMG[1] + IMG[3] / 2.0);
assert_eq!(handle_at(IMG, centre), None);
}
#[test]
fn a_press_well_clear_of_a_corner_grabs_nothing() {
assert_eq!(
handle_at(IMG, Point::new(IMG[0] - 20.0, IMG[1] - 20.0)),
None
);
assert_eq!(
handle_at(
IMG,
Point::new(IMG[0] + IMG[2] + 20.0, IMG[1] + IMG[3] + 20.0)
),
None
);
}
#[test]
fn the_edges_between_corners_grab_nothing() {
let mid_top = Point::new(IMG[0] + IMG[2] / 2.0, IMG[1]);
let mid_left = Point::new(IMG[0], IMG[1] + IMG[3] / 2.0);
assert_eq!(handle_at(IMG, mid_top), None);
assert_eq!(handle_at(IMG, mid_left), None);
}
}