#![allow(non_snake_case)]
use crate::composable;
use crate::modifier::Modifier;
use crate::text::{measure_text, AnnotatedString, TextOptions, TextOverflow, TextStyle};
use crate::text_field_modifier_node::TextFieldHandleMetrics;
use crate::widgets::box_widget::{Box, BoxSpec};
use crate::widgets::popup::Popup;
use crate::widgets::TextWithOptions;
use cranpose_ui_graphics::{Color, GraphicsLayer, Point, Rect, Size, TransformOrigin};
const MAGNIFICATION: f32 = 1.4;
const LOUPE_WIDTH: f32 = 150.0;
const LOUPE_HEIGHT: f32 = 46.0;
const GAP_ABOVE_FINGER: f32 = 44.0;
const BORDER_PX: f32 = 1.0;
const FRAME_FILL: Color = Color(1.0, 1.0, 1.0, 1.0);
const FRAME_BORDER: Color = Color(0.62, 0.64, 0.68, 1.0);
const CARET_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
fn caret_line(text: &str, style: &TextStyle, offset: usize) -> (String, f32) {
let offset = offset.min(text.len());
let before = &text[..offset];
let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
let after = &text[offset..];
let line_end = offset + after.find('\n').unwrap_or(after.len());
let line = text[line_start..line_end].to_string();
let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
(line, caret_x)
}
#[composable]
pub fn CursorMagnifier(
text: String,
style: TextStyle,
metrics: TextFieldHandleMetrics,
caret_offset: usize,
finger: Point,
) {
let (line, caret_x) = caret_line(&text, &style, caret_offset);
let line_height = metrics.line_height.max(1.0);
let line_width = measure_text(&AnnotatedString::from(line.as_str()), &style)
.width
.max(1.0);
let anchor = Rect {
x: (finger.x - LOUPE_WIDTH / 2.0).max(0.0),
y: (finger.y - GAP_ABOVE_FINGER - LOUPE_HEIGHT).max(0.0),
width: 0.0,
height: 0.0,
};
let magnified_line_height = line_height * MAGNIFICATION;
let translation_x = LOUPE_WIDTH / 2.0 - MAGNIFICATION * caret_x;
let translation_y = (LOUPE_HEIGHT - magnified_line_height) / 2.0;
let text_layer = GraphicsLayer {
transform_origin: TransformOrigin::new(0.0, 0.0),
scale: 1.0,
scale_x: MAGNIFICATION,
scale_y: MAGNIFICATION,
translation_x,
translation_y,
..GraphicsLayer::default()
};
let caret_top = (LOUPE_HEIGHT - magnified_line_height) / 2.0;
Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
let line = line.clone();
let style = style.clone();
let text_layer = text_layer.clone();
Box(
Modifier::empty()
.size(Size {
width: LOUPE_WIDTH,
height: LOUPE_HEIGHT,
})
.background(FRAME_BORDER)
.rounded_corners(10.0),
BoxSpec::default(),
move || {
let line = line.clone();
let style = style.clone();
let text_layer = text_layer.clone();
Box(
Modifier::empty()
.padding(BORDER_PX)
.size(Size {
width: LOUPE_WIDTH - 2.0 * BORDER_PX,
height: LOUPE_HEIGHT - 2.0 * BORDER_PX,
})
.background(FRAME_FILL)
.rounded_corners(9.0)
.clip_to_bounds(),
BoxSpec::default(),
move || {
TextWithOptions(
line.clone(),
Modifier::empty()
.required_size(Size {
width: line_width,
height: line_height,
})
.graphics_layer_value(text_layer.clone()),
style.clone(),
TextOptions {
overflow: TextOverflow::Visible,
soft_wrap: false,
..TextOptions::default()
},
);
Box(
Modifier::empty()
.absolute_offset(LOUPE_WIDTH / 2.0 - 1.0, caret_top)
.size(Size {
width: 2.0,
height: magnified_line_height,
})
.background(CARET_COLOR),
BoxSpec::default(),
|| {},
);
},
);
},
);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::LayoutEngine;
use crate::renderer::{HeadlessRenderer, RecordedRenderScene, RenderOp};
use crate::widgets::PopupHost;
use cranpose_core::{location_key, Composition, MemoryApplier};
fn render_magnifier(finger: Point, caret_offset: usize) -> RecordedRenderScene {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let metrics = TextFieldHandleMetrics {
focused: true,
touch: true,
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,
wrap_width: None,
};
let mut content = move || {
PopupHost(move || {
CursorMagnifier(
"hello world".to_string(),
TextStyle::default(),
metrics,
caret_offset,
finger,
);
});
};
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: 500.0,
height: 500.0,
},
)
.expect("layout");
applier.clear_runtime_handle();
drop(applier);
HeadlessRenderer::new().render(&layout)
}
fn text_ops(scene: &RecordedRenderScene) -> Vec<(String, Rect)> {
scene
.operations()
.iter()
.filter_map(|op| match op {
RenderOp::Text { value, rect, .. } => Some((value.clone(), *rect)),
_ => None,
})
.collect()
}
#[test]
fn magnifier_shows_the_caret_line_floated_above_the_finger() {
let _app_context = crate::render_state::app_context_test_scope();
let finger = Point { x: 120.0, y: 300.0 };
let scene = render_magnifier(finger, 3);
let texts = text_ops(&scene);
let line = texts
.iter()
.find(|(value, _)| value == "hello world")
.expect("magnifier renders the caret's line of text");
assert!(
line.1.y < finger.y,
"the magnified text {} must render above the finger at y={}",
line.1.y,
finger.y
);
}
#[test]
fn magnifier_lays_out_the_full_line_under_the_loupe_width() {
use crate::text::{measure_text_with_options, AnnotatedString, TextLayoutOptions};
let _app_context = crate::render_state::app_context_test_scope();
let wide_line = "abcdefghij ".repeat(30); let text = AnnotatedString::from(wide_line.as_str());
let style = TextStyle::default();
let loupe_max = Some(LOUPE_WIDTH);
let clamped = measure_text_with_options(
&text,
&style,
TextLayoutOptions {
overflow: TextOverflow::Clip,
soft_wrap: true,
max_lines: 1,
min_lines: 1,
},
loupe_max,
);
assert!(
clamped.width <= LOUPE_WIDTH + 1.0,
"sanity: a clamped line is bounded by the loupe width, got {}",
clamped.width
);
let full = measure_text_with_options(
&text,
&style,
TextLayoutOptions {
overflow: TextOverflow::Visible,
soft_wrap: false,
max_lines: 1,
min_lines: 1,
},
loupe_max,
);
assert!(
full.width > LOUPE_WIDTH * 3.0,
"magnifier text must keep its full intrinsic width under the narrow \
loupe max-width so far-right glyphs exist to draw (got {} vs loupe {LOUPE_WIDTH})",
full.width
);
}
#[test]
fn magnifier_text_node_lays_out_at_full_width_in_the_scene() {
let _app_context = crate::render_state::app_context_test_scope();
let wide_line = "abcdefghij ".repeat(30);
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let metrics = TextFieldHandleMetrics {
focused: true,
touch: true,
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,
wrap_width: None,
};
let line_for_content = wide_line.clone();
let caret_offset = wide_line.len() - 4;
let mut content = move || {
let line_for_content = line_for_content.clone();
PopupHost(move || {
CursorMagnifier(
line_for_content.clone(),
TextStyle::default(),
metrics,
caret_offset,
Point { x: 700.0, y: 400.0 },
);
});
};
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: 1080.0,
height: 800.0,
},
)
.expect("layout");
applier.clear_runtime_handle();
drop(applier);
let scene = HeadlessRenderer::new().render(&layout);
let line = text_ops(&scene)
.into_iter()
.find(|(value, _)| value == &wide_line)
.expect("magnifier renders the caret's line");
assert!(
line.1.width > LOUPE_WIDTH,
"the magnified line's Text node must lay out at full width (got {} vs \
loupe {LOUPE_WIDTH}); a clamped width means far-right glyphs are missing",
line.1.width
);
}
#[test]
fn magnifier_frame_is_drawn() {
use cranpose_ui_graphics::DrawPrimitive;
let _app_context = crate::render_state::app_context_test_scope();
let scene = render_magnifier(Point { x: 120.0, y: 300.0 }, 3);
let rects = scene
.operations()
.iter()
.filter(|op| {
matches!(
op,
RenderOp::Primitive {
primitive: DrawPrimitive::Rect { .. } | DrawPrimitive::RoundRect { .. },
..
}
)
})
.count();
assert!(
rects >= 2,
"the loupe should draw a frame and a caret indicator, got {rects} rects"
);
}
}