#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
use crate::dispatch::text_selection_line_range;
use crate::dispatch::{DragState, DragTarget, TextRegion};
use crate::event::Point;
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
use crate::event::Rect;
use crate::types::WidgetId;
#[derive(Debug, Clone)]
pub(crate) struct ActiveTextSelection {
pub region: WidgetId,
pub anchor: Point,
pub focus: Point,
}
#[derive(Default)]
pub(crate) struct TextSelectionState {
pub text_regions: Vec<TextRegion>,
active_selection: Option<ActiveTextSelection>,
last_text_region_id: Option<WidgetId>,
}
impl TextSelectionState {
pub fn register_text_region(&mut self, region: TextRegion) {
self.text_regions.push(region);
}
pub fn begin_frame(&mut self) {
self.text_regions.clear();
}
pub fn find_region(&self, id: &WidgetId) -> Option<&TextRegion> {
self.text_regions.iter().find(|r| &r.id == id)
}
pub fn active_text_selection(&self) -> Option<&ActiveTextSelection> {
self.active_selection.as_ref()
}
pub fn set_active_text_selection(&mut self, region: WidgetId, anchor: Point, focus: Point) {
self.last_text_region_id = Some(region);
self.active_selection = Some(ActiveTextSelection {
region,
anchor,
focus,
});
}
pub fn clear_selection_display(&mut self) {
self.active_selection = None;
}
pub fn clear_text_selection(&mut self, drag_state: &mut DragState) {
self.active_selection = None;
if matches!(drag_state.target(), Some(DragTarget::TextSelection { .. })) {
drag_state.end();
}
}
pub fn cancel_text_selection_drag(&mut self, drag_state: &mut DragState) {
if matches!(drag_state.target(), Some(DragTarget::TextSelection { .. })) {
drag_state.end();
}
}
pub fn track_focused_text_region(&mut self, id: WidgetId) {
self.last_text_region_id = Some(id);
}
pub fn select_all_text_region(&mut self) -> bool {
let region_id = if let Some(ref id) = self.last_text_region_id {
if self.text_regions.iter().any(|r| &r.id == id) {
*id
} else if self.text_regions.len() == 1 {
self.text_regions[0].id
} else {
return false;
}
} else if self.text_regions.len() == 1 {
self.text_regions[0].id
} else {
return false;
};
let bounds = match self.text_regions.iter().find(|r| r.id == region_id) {
Some(r) => r.bounds,
None => return false,
};
let anchor = Point::new(bounds.x, bounds.y);
let focus = Point::new(bounds.x + bounds.width, bounds.y + bounds.height);
self.set_active_text_selection(region_id, anchor, focus);
true
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) fn pixel_selection_ranges(
region_bounds: Rect,
anchor: Point,
focus: Point,
line_height: f32,
char_width: f32,
) -> Option<Vec<(u16, f32, f32)>> {
if line_height <= 0.0 || char_width <= 0.0 {
return None;
}
let bx = region_bounds.x;
let by = region_bounds.y;
let bw = region_bounds.width / char_width;
let bh = region_bounds.height / line_height;
let cell_bounds = Rect::new(0.0, 0.0, bw, bh);
let anchor_cell = Point {
x: (anchor.x - bx) / char_width,
y: (anchor.y - by) / line_height,
};
let focus_cell = Point {
x: (focus.x - bx) / char_width,
y: (focus.y - by) / line_height,
};
Some(text_selection_line_range(
anchor_cell,
focus_cell,
cell_bounds,
))
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) fn extract_lines_pixel(
region: &TextRegion,
anchor: Point,
focus: Point,
line_height: f32,
char_width: f32,
) -> String {
if region.lines.is_empty() {
return String::new();
}
let Some(ranges) =
pixel_selection_ranges(region.bounds, anchor, focus, line_height, char_width)
else {
return String::new();
};
let mut lines: Vec<String> = Vec::with_capacity(ranges.len());
for (row_cell, col_start, col_end) in ranges {
let line_idx = row_cell as usize;
let Some(src) = region.lines.get(line_idx) else {
continue;
};
let col_start = col_start as usize;
let col_end = col_end as usize;
let chars: Vec<char> = src.chars().collect();
let s: String = chars
.get(col_start.min(chars.len())..col_end.min(chars.len()))
.unwrap_or(&[])
.iter()
.collect();
lines.push(s.trim_end().to_string());
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Rect;
fn region(id: &str, x: f32, y: f32, w: f32, h: f32, lines: Vec<&str>) -> TextRegion {
TextRegion {
id: WidgetId::new(id),
bounds: Rect::new(x, y, w, h),
lines: lines.into_iter().map(String::from).collect(),
}
}
#[test]
fn register_and_clear_round_trip() {
let mut state = TextSelectionState::default();
state.register_text_region(region("r1", 0.0, 0.0, 100.0, 50.0, vec![]));
state.register_text_region(region("r2", 0.0, 50.0, 100.0, 50.0, vec![]));
assert_eq!(state.text_regions.len(), 2);
state.begin_frame();
assert!(state.text_regions.is_empty());
}
#[test]
fn set_and_clear_active_selection() {
let mut state = TextSelectionState::default();
state.register_text_region(region("r", 0.0, 0.0, 200.0, 100.0, vec![]));
assert!(state.active_text_selection().is_none());
state.set_active_text_selection(
WidgetId::new("r"),
Point::new(0.0, 0.0),
Point::new(50.0, 20.0),
);
assert!(state.active_text_selection().is_some());
state.clear_selection_display();
assert!(state.active_text_selection().is_none());
}
#[test]
fn clear_text_selection_also_ends_a_text_selection_drag() {
let mut state = TextSelectionState::default();
let mut drag = DragState::new();
drag.begin(DragTarget::TextSelection {
region: WidgetId::new("r"),
anchor: Point::new(0.0, 0.0),
});
state.set_active_text_selection(
WidgetId::new("r"),
Point::new(0.0, 0.0),
Point::new(10.0, 10.0),
);
state.clear_text_selection(&mut drag);
assert!(state.active_text_selection().is_none());
assert!(
drag.target().is_none(),
"clear_text_selection must also end an in-progress TextSelection drag"
);
}
#[test]
fn clear_text_selection_leaves_an_unrelated_drag_alone() {
let mut state = TextSelectionState::default();
let mut drag = DragState::new();
drag.begin(DragTarget::ScrollbarY {
widget: WidgetId::new("sb"),
track_start: 0.0,
track_length: 100.0,
thumb_length: 20.0,
max_scroll: 10,
grab_offset: 0.0,
inverted: false,
});
state.clear_text_selection(&mut drag);
assert!(
drag.target().is_some(),
"clear_text_selection must not touch a non-TextSelection drag"
);
}
#[test]
fn cancel_text_selection_drag_preserves_the_displayed_selection() {
let mut state = TextSelectionState::default();
let mut drag = DragState::new();
drag.begin(DragTarget::TextSelection {
region: WidgetId::new("r"),
anchor: Point::new(0.0, 0.0),
});
state.set_active_text_selection(
WidgetId::new("r"),
Point::new(0.0, 0.0),
Point::new(10.0, 10.0),
);
state.cancel_text_selection_drag(&mut drag);
assert!(drag.target().is_none());
assert!(
state.active_text_selection().is_some(),
"cancel_text_selection_drag must not clear the displayed selection"
);
}
#[test]
fn select_all_targets_the_sole_region() {
let mut state = TextSelectionState::default();
state.register_text_region(region("body", 0.0, 0.0, 10.0, 5.0, vec![]));
assert!(state.select_all_text_region());
let sel = state
.active_text_selection()
.expect("selection should be active");
assert_eq!(sel.anchor, Point::new(0.0, 0.0));
assert_eq!(sel.focus, Point::new(10.0, 5.0));
}
#[test]
fn select_all_returns_false_with_no_regions() {
let mut state = TextSelectionState::default();
assert!(!state.select_all_text_region());
assert!(state.active_text_selection().is_none());
}
#[test]
fn select_all_prefers_the_tracked_region_over_an_ambiguous_pair() {
let mut state = TextSelectionState::default();
state.register_text_region(region("r1", 0.0, 0.0, 10.0, 5.0, vec![]));
state.register_text_region(region("r2", 0.0, 5.0, 10.0, 5.0, vec![]));
state.track_focused_text_region(WidgetId::new("r2"));
assert!(state.select_all_text_region());
assert_eq!(
state.active_text_selection().unwrap().region,
WidgetId::new("r2")
);
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[test]
fn extract_lines_pixel_single_row() {
let region = region(
"body",
0.0,
0.0,
200.0,
100.0,
vec!["The quick brown fox jumps over the lazy dog."],
);
let text = extract_lines_pixel(
®ion,
Point::new(0.0, 0.0),
Point::new(200.0, 10.0),
20.0,
10.0,
);
assert_eq!(text, "The quick brown fox");
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[test]
fn extract_lines_pixel_multi_row() {
let region = region(
"body",
0.0,
0.0,
200.0,
100.0,
vec!["first line here", "second line here"],
);
let text = extract_lines_pixel(
®ion,
Point::new(0.0, 0.0),
Point::new(200.0, 30.0),
20.0,
10.0,
);
assert_eq!(text, "first line here\nsecond line here");
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[test]
fn extract_lines_pixel_empty_when_no_lines_content() {
let region = region("body", 0.0, 0.0, 200.0, 32.0, vec![]);
let text = extract_lines_pixel(
®ion,
Point::new(0.0, 0.0),
Point::new(200.0, 32.0),
16.0,
8.0,
);
assert_eq!(text, "");
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[test]
fn extract_lines_pixel_empty_when_metrics_unknown() {
let region = region("body", 0.0, 0.0, 200.0, 32.0, vec!["hello"]);
let text = extract_lines_pixel(
®ion,
Point::new(0.0, 0.0),
Point::new(200.0, 32.0),
0.0,
0.0,
);
assert_eq!(text, "");
}
}