#![allow(non_snake_case)]
use crate::clipboard_session::{clipboard_read_text, clipboard_write_text};
use crate::composable;
use crate::layout::policies::EmptyMeasurePolicy;
use crate::modifier::Modifier;
use crate::text::{measure_text, AnnotatedString, TextStyle};
use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_paste, dispatch_select_all};
use crate::text_field_modifier_node::{
TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
};
use crate::text_selection::{selection_after_handle_drag, HandleKind, HANDLE_RADIUS};
use crate::widgets::{Layout, SelectionHandle, TextSelectionMenu};
use cranpose_core::{mutableStateOf, remember, NodeId, SideEffect};
use cranpose_foundation::modifier_element;
use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
use cranpose_ui_graphics::{Color, Point};
use std::cell::Cell;
use std::rc::Rc;
const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
fn handle_tip_window_pos(
text: &str,
style: &TextStyle,
metrics: &TextFieldHandleMetrics,
offset: usize,
) -> Point {
let offset = offset.min(text.len());
let before = &text[..offset];
let line_index = before.matches('\n').count();
let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
let caret_x = measure_text(&AnnotatedString::from(&before[line_start..]), style).width;
Point {
x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
y: metrics.node_origin.y
+ metrics.padding_top
+ (line_index as f32 + 1.0) * metrics.line_height,
}
}
fn window_pos_to_offset(
text: &str,
style: &TextStyle,
metrics: &TextFieldHandleMetrics,
window_pos: Point,
) -> usize {
let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
+ metrics.scroll_offset)
.max(0.0);
let local_y =
(window_pos.y - metrics.node_origin.y - metrics.padding_top - metrics.line_height).max(0.0);
crate::text::get_offset_for_position(&AnnotatedString::from(text), style, local_x, local_y)
}
#[composable]
pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
BasicTextFieldWithOptions(
state,
modifier,
BasicTextFieldOptions {
text_style: style,
..BasicTextFieldOptions::default()
},
)
}
#[derive(Debug, Clone, PartialEq)]
pub struct BasicTextFieldOptions {
pub text_style: TextStyle,
pub cursor_color: Color,
pub line_limits: TextFieldLineLimits,
}
impl Default for BasicTextFieldOptions {
fn default() -> Self {
Self {
text_style: TextStyle::default(),
cursor_color: Color(0.0, 0.0, 0.0, 1.0), line_limits: TextFieldLineLimits::default(),
}
}
}
#[composable]
pub fn BasicTextFieldWithOptions(
state: TextFieldState,
modifier: Modifier,
options: BasicTextFieldOptions,
) -> NodeId {
let _text = state.text();
let _selection = state.selection();
let controller =
remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
.with_cursor_color(options.cursor_color)
.with_line_limits(options.line_limits)
.with_handle_controller(controller.clone());
let text_field_modifier = modifier_element(text_field_element);
let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
let combined_modifier = modifier.then(final_modifier);
let node = Layout(
combined_modifier,
EmptyMeasurePolicy,
|| {}, );
SelectionHandles(state, options.text_style, controller);
node
}
#[composable]
fn SelectionHandles(
state: TextFieldState,
style: TextStyle,
controller: TextFieldHandleController,
) {
let selection = state.selection();
let current_range = (selection.min(), selection.max());
let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
let previous_range: Rc<Cell<(usize, usize)>> =
remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
{
let previous_range = Rc::clone(&previous_range);
SideEffect(move || {
if previous_range.get() != current_range {
previous_range.set(current_range);
menu_open.set(true);
}
});
}
let Some(metrics) = controller.metrics() else {
return;
};
if !metrics.focused || !metrics.touch {
return;
}
let text = state.text();
if selection.collapsed() {
let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
SelectionHandle(
HandleKind::Cursor,
tip,
HANDLE_RADIUS,
HANDLE_COLOR,
move |pos| on_drag(pos),
|| {},
);
} else {
let start = selection.min();
let end = selection.max();
let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
let on_drag_start = drag_edge_closure(
HandleKind::SelectionStart,
state.clone(),
style.clone(),
controller.clone(),
);
SelectionHandle(
HandleKind::SelectionStart,
start_tip,
HANDLE_RADIUS,
HANDLE_COLOR,
move |pos| on_drag_start(pos),
|| {},
);
let on_drag_end = drag_edge_closure(
HandleKind::SelectionEnd,
state.clone(),
style.clone(),
controller.clone(),
);
SelectionHandle(
HandleKind::SelectionEnd,
end_tip,
HANDLE_RADIUS,
HANDLE_COLOR,
move |pos| on_drag_end(pos),
|| {},
);
if menu_open.value() {
let menu_anchor = Point {
x: (start_tip.x + end_tip.x) * 0.5,
y: start_tip.y - metrics.line_height,
};
let can_paste = clipboard_read_text().is_some();
TextSelectionMenu(
menu_anchor,
can_paste,
move || {
if let Some(text) = dispatch_copy() {
clipboard_write_text(&text);
}
menu_open.set(false);
},
move || {
if let Some(text) = dispatch_cut() {
clipboard_write_text(&text);
}
menu_open.set(false);
},
move || {
if let Some(text) = clipboard_read_text() {
dispatch_paste(&text);
}
menu_open.set(false);
},
move || {
dispatch_select_all();
menu_open.set(false);
},
);
}
}
}
fn drag_caret_closure(
state: TextFieldState,
style: TextStyle,
controller: TextFieldHandleController,
) -> Rc<dyn Fn(Point)> {
Rc::new(move |window_pos: Point| {
let Some(metrics) = controller.metrics() else {
return;
};
let text = state.text();
let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
state.set_selection(TextRange::new(offset, offset));
crate::request_render_invalidation();
})
}
fn drag_edge_closure(
dragged: HandleKind,
state: TextFieldState,
style: TextStyle,
controller: TextFieldHandleController,
) -> Rc<dyn Fn(Point)> {
Rc::new(move |window_pos: Point| {
let Some(metrics) = controller.metrics() else {
return;
};
let text = state.text();
let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
let selection = state.selection();
let fixed_edge = match dragged {
HandleKind::SelectionStart => selection.max(),
_ => selection.min(),
};
let (min, max) =
selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
state.set_selection(TextRange::new(min, max));
crate::request_render_invalidation();
})
}
#[cfg(test)]
mod tests {
use super::*;
use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
use std::sync::Arc;
fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
let _runtime = Runtime::new(Arc::new(DefaultScheduler));
f()
}
fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
use crate::layout::LayoutEngine;
use crate::renderer::HeadlessRenderer;
use crate::widgets::PopupHost;
use cranpose_ui_graphics::Size;
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let state = TextFieldState::new("hello world");
let mut content = {
let state = state.clone();
move || {
let state = state.clone();
PopupHost(move || {
let controller = TextFieldHandleController::new();
controller.publish(TextFieldHandleMetrics {
focused: true,
touch,
node_origin: Point { x: 0.0, y: 10.0 },
padding_left: 0.0,
padding_top: 0.0,
scroll_offset: 0.0,
line_height: 18.0,
});
SelectionHandles(state.clone(), TextStyle::default(), controller);
});
}
};
composition.render(key, &mut content).expect("render");
for _ in 0..16 {
if !composition.should_render() {
break;
}
composition.reconcile(key, &mut content).expect("reconcile");
}
let root = composition.root().expect("root");
let handle = composition.runtime_handle();
let mut applier = composition.applier_mut();
applier.set_runtime_handle(handle);
let layout = applier
.compute_layout(
root,
Size {
width: 400.0,
height: 400.0,
},
)
.expect("layout");
applier.clear_runtime_handle();
drop(applier);
HeadlessRenderer::new().render(&layout)
}
fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
use crate::layout::LayoutEngine;
use crate::renderer::HeadlessRenderer;
use crate::widgets::PopupHost;
use cranpose_ui_graphics::Size;
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let state = TextFieldState::new("hello world");
let mut content = {
let state = state.clone();
move || {
let state = state.clone();
PopupHost(move || {
let controller = TextFieldHandleController::new();
if state.selection() != TextRange::new(0, 5) {
state.set_selection(TextRange::new(0, 5));
}
controller.publish(TextFieldHandleMetrics {
focused: true,
touch,
node_origin: Point { x: 0.0, y: 40.0 },
padding_left: 0.0,
padding_top: 0.0,
scroll_offset: 0.0,
line_height: 18.0,
});
SelectionHandles(state.clone(), TextStyle::default(), controller);
});
}
};
composition.render(key, &mut content).expect("render");
for _ in 0..16 {
if !composition.should_render() {
break;
}
composition.reconcile(key, &mut content).expect("reconcile");
}
let root = composition.root().expect("root");
let handle = composition.runtime_handle();
let mut applier = composition.applier_mut();
applier.set_runtime_handle(handle);
let layout = applier
.compute_layout(
root,
Size {
width: 400.0,
height: 400.0,
},
)
.expect("layout");
applier.clear_runtime_handle();
drop(applier);
HeadlessRenderer::new().render(&layout)
}
fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
use crate::renderer::RenderOp;
scene
.operations()
.iter()
.filter_map(|op| match op {
RenderOp::Text { value, .. } => Some(value.clone()),
_ => None,
})
.collect()
}
#[test]
fn context_menu_shows_for_touch_selection_only() {
let _app_context = crate::render_state::app_context_test_scope();
let touch = text_values(&render_range_menu(true));
assert!(
touch.iter().any(|t| t == "Copy"),
"touch selection should show the Copy menu item, got {touch:?}"
);
assert!(
touch.iter().any(|t| t == "Cut"),
"expected Cut, got {touch:?}"
);
assert!(
touch.iter().any(|t| t == "Select all"),
"expected Select all, got {touch:?}"
);
let mouse = text_values(&render_range_menu(false));
assert!(
!mouse.iter().any(|t| t == "Copy"),
"mouse selection must not show the finger contextual menu, got {mouse:?}"
);
}
fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
use crate::renderer::RenderOp;
use cranpose_ui_graphics::DrawPrimitive;
scene
.operations()
.iter()
.filter(|op| {
matches!(
op,
RenderOp::Primitive {
primitive: DrawPrimitive::Image { .. },
..
}
)
})
.count()
}
#[test]
fn cursor_handle_shows_for_touch_only() {
let _app_context = crate::render_state::app_context_test_scope();
assert_eq!(
image_count(&render_collapsed_handles(true)),
1,
"a touch caret should show one finger cursor handle in the overlay"
);
assert_eq!(
image_count(&render_collapsed_handles(false)),
0,
"a mouse caret should keep a clean caret with no finger handle"
);
}
#[test]
fn basic_text_field_creates_node() {
let _app_context = crate::render_state::app_context_test_scope();
let mut composition = Composition::new(MemoryApplier::new());
let state = TextFieldState::new("Test content");
let result = composition.render(location_key(file!(), line!(), column!()), {
let state = state.clone();
move || {
BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
}
});
assert!(result.is_ok());
assert!(composition.root().is_some());
}
#[test]
fn basic_text_field_state_updates() {
let _app_context = crate::render_state::app_context_test_scope();
with_test_runtime(|| {
let state = TextFieldState::new("Hello");
assert_eq!(state.text(), "Hello");
state.edit(|buffer| {
buffer.place_cursor_at_end();
buffer.insert("!");
});
assert_eq!(state.text(), "Hello!");
});
}
}