#[cfg(any(test, feature = "text-input-core"))]
use std::ops::Range;
use crate::WidgetId;
#[cfg(feature = "text-input-core")]
use crate::{ClipboardData, ClipboardService, ZsTextEditCommand, ZsuiResult};
#[cfg(feature = "text-input-core")]
use unicode_segmentation::UnicodeSegmentation;
#[cfg(feature = "password-box")]
use zeroize::Zeroizing;
#[cfg(feature = "text-input-core")]
const NATIVE_TEXT_UNDO_LIMIT: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NativeTextSelection {
pub anchor: usize,
pub caret: usize,
}
impl NativeTextSelection {
pub(crate) const fn collapsed(caret: usize) -> Self {
Self {
anchor: caret,
caret,
}
}
pub(crate) const fn ordered(self) -> (usize, usize) {
if self.anchor <= self.caret {
(self.anchor, self.caret)
} else {
(self.caret, self.anchor)
}
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) const fn is_collapsed(self) -> bool {
self.anchor == self.caret
}
pub(crate) fn clamp(self, value: &str) -> Self {
let len = char_count(value);
Self {
anchor: snap_grapheme_index_with_len(value, self.anchor, len),
caret: snap_grapheme_index_with_len(value, self.caret, len),
}
}
}
#[cfg(feature = "text-input-core")]
impl From<NativeTextSelection> for crate::ZsTextSelection {
fn from(selection: NativeTextSelection) -> Self {
Self {
anchor: selection.anchor,
caret: selection.caret,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NativeTextEditState {
pub widget: WidgetId,
pub selection: NativeTextSelection,
pub preferred_visual_x: Option<i32>,
pub first_visible_visual_row: usize,
pub horizontal_scroll_px: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NativeTextDragState {
pub widget: WidgetId,
pub anchor: usize,
}
impl NativeTextEditState {
pub(crate) fn at_end(widget: WidgetId, value: &str) -> Self {
Self {
widget,
selection: NativeTextSelection::collapsed(char_count(value)),
preferred_visual_x: None,
first_visible_visual_row: 0,
horizontal_scroll_px: 0,
}
}
pub(crate) fn clamp(&mut self, value: &str) {
let clamped = self.selection.clamp(value);
if clamped != self.selection {
self.preferred_visual_x = None;
self.first_visible_visual_row = 0;
self.horizontal_scroll_px = 0;
}
self.selection = clamped;
}
}
#[cfg(any(test, feature = "text-input-core"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeTextMovement {
Left,
Right,
Home,
End,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct NativeTextEditResult {
pub handled: bool,
pub text_changed: bool,
pub selection_changed: bool,
}
#[cfg(feature = "text-input-core")]
#[derive(Debug, Clone, PartialEq, Eq)]
enum NativeTextHistoryValue {
Plain(String),
#[cfg(feature = "password-box")]
Secure(Zeroizing<String>),
}
#[cfg(feature = "text-input-core")]
impl NativeTextHistoryValue {
fn new(value: &str, secure: bool) -> Self {
#[cfg(feature = "password-box")]
if secure {
return Self::Secure(Zeroizing::new(value.to_string()));
}
#[cfg(not(feature = "password-box"))]
let _ = secure;
Self::Plain(value.to_string())
}
fn as_str(&self) -> &str {
match self {
Self::Plain(value) => value,
#[cfg(feature = "password-box")]
Self::Secure(value) => value.as_str(),
}
}
fn is_secure(&self) -> bool {
match self {
Self::Plain(_) => false,
#[cfg(feature = "password-box")]
Self::Secure(_) => true,
}
}
fn into_string(self) -> String {
match self {
Self::Plain(value) => value,
#[cfg(feature = "password-box")]
Self::Secure(mut value) => std::mem::take(&mut *value),
}
}
}
#[cfg(feature = "text-input-core")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct NativeTextSnapshot {
value: NativeTextHistoryValue,
selection: NativeTextSelection,
}
#[cfg(feature = "text-input-core")]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct NativeTextHistory {
widget: Option<WidgetId>,
tracked_value: Option<NativeTextHistoryValue>,
undo: Vec<NativeTextSnapshot>,
}
#[cfg(feature = "text-input-core")]
impl NativeTextHistory {
pub(crate) fn record_text_change(
&mut self,
widget: WidgetId,
before_value: &str,
before_selection: NativeTextSelection,
after_value: &str,
secure: bool,
) {
self.synchronize(widget, before_value, secure);
if before_value == after_value {
return;
}
if self.undo.len() == NATIVE_TEXT_UNDO_LIMIT {
self.undo.remove(0);
}
self.undo.push(NativeTextSnapshot {
value: NativeTextHistoryValue::new(before_value, secure),
selection: before_selection.clamp(before_value),
});
self.tracked_value = Some(NativeTextHistoryValue::new(after_value, secure));
}
fn synchronize(&mut self, widget: WidgetId, value: &str, secure: bool) {
let synchronized = self.widget == Some(widget)
&& self
.tracked_value
.as_ref()
.is_some_and(|tracked| tracked.as_str() == value && tracked.is_secure() == secure);
if !synchronized {
self.widget = Some(widget);
self.tracked_value = Some(NativeTextHistoryValue::new(value, secure));
self.undo.clear();
}
}
fn undo(
&mut self,
widget: WidgetId,
value: &str,
secure: bool,
) -> Option<(String, NativeTextSelection)> {
self.synchronize(widget, value, secure);
let snapshot = self.undo.pop()?;
self.tracked_value = Some(snapshot.value.clone());
Some((snapshot.value.into_string(), snapshot.selection))
}
}
#[cfg(feature = "text-input-core")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct NativeTextCommandResult {
pub handled: bool,
pub text_changed: bool,
pub selection_changed: bool,
pub clipboard_read: bool,
pub clipboard_write: bool,
pub undo_applied: bool,
}
#[cfg(all(
feature = "text-input-core",
any(
all(target_os = "windows", feature = "windows-win32"),
all(target_os = "macos", feature = "macos-appkit"),
all(
target_os = "linux",
any(feature = "linux-direct-host", feature = "linux-gtk")
)
)
))]
pub(crate) fn text_edit_command_for_shortcut_character(
character: char,
) -> Option<ZsTextEditCommand> {
match character.to_ascii_lowercase() {
'a' => Some(ZsTextEditCommand::SelectAll),
'c' => Some(ZsTextEditCommand::Copy),
'v' => Some(ZsTextEditCommand::Paste),
'x' => Some(ZsTextEditCommand::Cut),
'z' => Some(ZsTextEditCommand::Undo),
_ => None,
}
}
#[cfg(feature = "text-input-core")]
pub(crate) fn apply_text_edit_command(
command: ZsTextEditCommand,
capabilities: crate::view::ViewTextEditCapabilities,
secure: bool,
widget: WidgetId,
value: &mut String,
selection: &mut NativeTextSelection,
history: &mut NativeTextHistory,
clipboard: &mut impl ClipboardService,
) -> ZsuiResult<NativeTextCommandResult> {
*selection = selection.clamp(value);
let mut result = NativeTextCommandResult {
handled: true,
..NativeTextCommandResult::default()
};
if !capabilities.allows(command) {
return Ok(result);
}
let before_value = NativeTextHistoryValue::new(value, secure);
let before_selection = *selection;
match command {
ZsTextEditCommand::Undo => {
if let Some((next_value, next_selection)) = history.undo(widget, value, secure) {
*value = next_value;
*selection = next_selection.clamp(value);
result.undo_applied = true;
}
}
ZsTextEditCommand::Cut => {
if let Some(selected) = selected_text(value, *selection) {
clipboard.write_clipboard(&ClipboardData::text(selected))?;
result.clipboard_write = true;
delete_selection(value, selection);
history.record_text_change(
widget,
before_value.as_str(),
before_selection,
value,
secure,
);
}
}
ZsTextEditCommand::Copy => {
if let Some(selected) = selected_text(value, *selection) {
clipboard.write_clipboard(&ClipboardData::text(selected))?;
result.clipboard_write = true;
}
}
ZsTextEditCommand::Paste => {
result.clipboard_read = true;
if let Some(ClipboardData::Text(text)) = clipboard.read_clipboard()? {
#[cfg(feature = "password-box")]
let text = Zeroizing::new(text);
if !text.is_empty() {
insert_text(value, selection, &text);
history.record_text_change(
widget,
before_value.as_str(),
before_selection,
value,
secure,
);
}
}
}
ZsTextEditCommand::SelectAll => {
*selection = NativeTextSelection {
anchor: 0,
caret: char_count(value),
};
}
}
result.text_changed = value != before_value.as_str();
result.selection_changed = *selection != before_selection;
Ok(result)
}
#[cfg(feature = "text-input-core")]
pub(crate) fn apply_text_input(
value: &mut String,
selection: &mut NativeTextSelection,
text: &str,
multiline: bool,
) -> NativeTextEditResult {
let mut result = NativeTextEditResult::default();
let mut previous_was_carriage_return = false;
for ch in text.chars() {
let edit = match ch {
'\u{8}' => delete_backward(value, selection),
'\u{7f}' => delete_forward(value, selection),
'\r' if multiline => {
previous_was_carriage_return = true;
insert_text(value, selection, "\n")
}
'\n' if multiline && !previous_was_carriage_return => {
insert_text(value, selection, "\n")
}
ch if !ch.is_control() => {
let mut buffer = [0_u8; 4];
insert_text(value, selection, ch.encode_utf8(&mut buffer))
}
_ => NativeTextEditResult::default(),
};
result.handled |= edit.handled;
result.text_changed |= edit.text_changed;
result.selection_changed |= edit.selection_changed;
if ch != '\r' {
previous_was_carriage_return = false;
}
}
result
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) fn insert_text(
value: &mut String,
selection: &mut NativeTextSelection,
text: &str,
) -> NativeTextEditResult {
if text.is_empty() {
return NativeTextEditResult::default();
}
replace_selection(value, selection, text)
}
#[cfg(feature = "text-input-core")]
pub(crate) fn delete_selection(
value: &mut String,
selection: &mut NativeTextSelection,
) -> NativeTextEditResult {
replace_selection(value, selection, "")
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) fn delete_backward(
value: &mut String,
selection: &mut NativeTextSelection,
) -> NativeTextEditResult {
*selection = selection.clamp(value);
let (start, end) = selection.ordered();
if start != end {
return replace_char_range(value, selection, start..end, "");
}
if start == 0 {
return NativeTextEditResult {
handled: true,
..NativeTextEditResult::default()
};
}
let previous = previous_grapheme_boundary(value, start);
replace_char_range(value, selection, previous..start, "")
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) fn delete_forward(
value: &mut String,
selection: &mut NativeTextSelection,
) -> NativeTextEditResult {
*selection = selection.clamp(value);
let (start, end) = selection.ordered();
if start != end {
return replace_char_range(value, selection, start..end, "");
}
let len = char_count(value);
if end >= len {
return NativeTextEditResult {
handled: true,
..NativeTextEditResult::default()
};
}
let next = next_grapheme_boundary(value, end);
replace_char_range(value, selection, end..next, "")
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) fn move_selection(
value: &str,
selection: &mut NativeTextSelection,
movement: NativeTextMovement,
extend: bool,
multiline: bool,
) -> NativeTextEditResult {
let before = selection.clamp(value);
*selection = before;
let len = char_count(value);
let (start, end) = before.ordered();
let target = match movement {
NativeTextMovement::Left if !extend && !before.is_collapsed() => start,
NativeTextMovement::Right if !extend && !before.is_collapsed() => end,
NativeTextMovement::Left => previous_grapheme_boundary(value, before.caret),
NativeTextMovement::Right => next_grapheme_boundary(value, before.caret).min(len),
NativeTextMovement::Home if multiline => line_start(value, before.caret),
NativeTextMovement::End if multiline => line_end(value, before.caret),
NativeTextMovement::Home => 0,
NativeTextMovement::End => len,
};
move_selection_to(value, selection, target, extend)
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) fn move_selection_to(
value: &str,
selection: &mut NativeTextSelection,
target: usize,
extend: bool,
) -> NativeTextEditResult {
let before = selection.clamp(value);
*selection = before;
let target = snap_grapheme_index(value, target);
if extend {
selection.caret = target;
} else {
*selection = NativeTextSelection::collapsed(target);
}
NativeTextEditResult {
handled: true,
selection_changed: *selection != before,
..NativeTextEditResult::default()
}
}
pub(crate) fn set_pointer_selection(
value: &str,
selection: &mut NativeTextSelection,
anchor: usize,
caret: usize,
) -> NativeTextEditResult {
let before = selection.clamp(value);
*selection = NativeTextSelection { anchor, caret }.clamp(value);
NativeTextEditResult {
handled: true,
selection_changed: *selection != before,
..NativeTextEditResult::default()
}
}
pub(crate) fn char_count(value: &str) -> usize {
value.chars().count()
}
pub(crate) fn grapheme_boundaries(value: &str) -> Vec<usize> {
#[cfg(feature = "text-input-core")]
{
let mut boundaries = Vec::with_capacity(value.len().saturating_add(1));
let mut scalar_index = 0_usize;
boundaries.push(0);
for grapheme in UnicodeSegmentation::graphemes(value, true) {
scalar_index = scalar_index.saturating_add(grapheme.chars().count());
boundaries.push(scalar_index);
}
boundaries
}
#[cfg(not(feature = "text-input-core"))]
{
(0..=char_count(value)).collect()
}
}
pub(crate) fn snap_grapheme_index(value: &str, index: usize) -> usize {
let len = char_count(value);
snap_grapheme_index_with_len(value, index, len)
}
fn snap_grapheme_index_with_len(value: &str, index: usize, len: usize) -> usize {
let index = index.min(len);
if index == 0 || index == len {
return index;
}
let boundaries = grapheme_boundaries(value);
match boundaries.binary_search(&index) {
Ok(position) => boundaries[position],
Err(position) => {
let previous = boundaries[position.saturating_sub(1)];
let next = boundaries.get(position).copied().unwrap_or(previous);
if index.saturating_sub(previous) <= next.saturating_sub(index) {
previous
} else {
next
}
}
}
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) fn previous_grapheme_boundary(value: &str, index: usize) -> usize {
let index = index.min(char_count(value));
let boundaries = grapheme_boundaries(value);
match boundaries.binary_search(&index) {
Ok(position) => boundaries[position.saturating_sub(1)],
Err(position) => boundaries[position.saturating_sub(1)],
}
}
#[cfg(any(test, feature = "text-input-core"))]
pub(crate) fn next_grapheme_boundary(value: &str, index: usize) -> usize {
let index = index.min(char_count(value));
let boundaries = grapheme_boundaries(value);
match boundaries.binary_search(&index) {
Ok(position) => boundaries
.get(position.saturating_add(1))
.copied()
.unwrap_or(index),
Err(position) => boundaries.get(position).copied().unwrap_or(index),
}
}
#[cfg(test)]
pub(crate) fn grapheme_count_in_range(value: &str, start: usize, end: usize) -> usize {
let start = start.min(char_count(value));
let end = end.min(char_count(value)).max(start);
grapheme_boundaries(value)
.into_iter()
.filter(|boundary| *boundary > start && *boundary <= end)
.count()
}
#[cfg(test)]
pub(crate) fn grapheme_index_for_column(
value: &str,
start: usize,
end: usize,
column: usize,
) -> usize {
let start = start.min(char_count(value));
let end = end.min(char_count(value)).max(start);
if column == 0 {
return start;
}
grapheme_boundaries(value)
.into_iter()
.filter(|boundary| *boundary > start && *boundary <= end)
.nth(column.saturating_sub(1))
.unwrap_or(end)
}
pub(crate) fn char_to_byte_index(value: &str, char_index: usize) -> usize {
value
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(value.len())
}
#[cfg(feature = "text-input-core")]
fn selected_text(value: &str, selection: NativeTextSelection) -> Option<&str> {
let (start, end) = selection.clamp(value).ordered();
if start == end {
return None;
}
value.get(char_to_byte_index(value, start)..char_to_byte_index(value, end))
}
#[cfg(any(test, feature = "text-input-core"))]
fn replace_selection(
value: &mut String,
selection: &mut NativeTextSelection,
replacement: &str,
) -> NativeTextEditResult {
*selection = selection.clamp(value);
let (start, end) = selection.ordered();
replace_char_range(value, selection, start..end, replacement)
}
#[cfg(any(test, feature = "text-input-core"))]
fn replace_char_range(
value: &mut String,
selection: &mut NativeTextSelection,
range: Range<usize>,
replacement: &str,
) -> NativeTextEditResult {
let before_selection = *selection;
let start_byte = char_to_byte_index(value, range.start);
let end_byte = char_to_byte_index(value, range.end);
let text_changed = value.get(start_byte..end_byte) != Some(replacement);
if text_changed {
value.replace_range(start_byte..end_byte, replacement);
}
let caret = range.start.saturating_add(char_count(replacement));
*selection = NativeTextSelection::collapsed(caret);
NativeTextEditResult {
handled: true,
text_changed,
selection_changed: *selection != before_selection,
}
}
#[cfg(any(test, feature = "text-input-core"))]
fn line_start(value: &str, caret: usize) -> usize {
value
.chars()
.take(caret)
.enumerate()
.filter_map(|(index, character)| (character == '\n').then_some(index + 1))
.last()
.unwrap_or(0)
}
#[cfg(any(test, feature = "text-input-core"))]
fn line_end(value: &str, caret: usize) -> usize {
value
.chars()
.enumerate()
.skip(caret)
.find_map(|(index, character)| (character == '\n').then_some(index))
.unwrap_or_else(|| char_count(value))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "text-input-core")]
#[derive(Default)]
struct TestClipboard {
value: Option<ClipboardData>,
}
#[cfg(feature = "text-input-core")]
impl ClipboardService for TestClipboard {
fn read_clipboard(&mut self) -> ZsuiResult<Option<ClipboardData>> {
Ok(self.value.clone())
}
fn write_clipboard(&mut self, data: &ClipboardData) -> ZsuiResult<()> {
self.value = Some(data.clone());
Ok(())
}
}
#[test]
fn unicode_insert_replaces_selection_and_keeps_character_indices() {
let mut value = "A中文Z".to_string();
let mut selection = NativeTextSelection {
anchor: 1,
caret: 3,
};
let result = insert_text(&mut value, &mut selection, "🙂");
assert!(result.text_changed);
assert_eq!(value, "A🙂Z");
assert_eq!(selection, NativeTextSelection::collapsed(2));
}
#[test]
fn backward_and_forward_delete_remove_complete_unicode_scalars() {
let mut value = "A🙂中Z".to_string();
let mut selection = NativeTextSelection::collapsed(2);
let backward = delete_backward(&mut value, &mut selection);
let forward = delete_forward(&mut value, &mut selection);
assert!(backward.text_changed);
assert!(forward.text_changed);
assert_eq!(value, "AZ");
assert_eq!(selection, NativeTextSelection::collapsed(1));
}
#[cfg(feature = "text-input-core")]
#[test]
fn navigation_and_deletion_never_split_extended_grapheme_clusters() {
let value = "A\u{65}\u{301}👨👩👧👦Z";
let combining_end = 3;
let family_end = 10;
assert_eq!(
grapheme_boundaries(value),
vec![0, 1, combining_end, family_end, 11]
);
assert_eq!(snap_grapheme_index(value, 2), 1);
assert_eq!(snap_grapheme_index(value, 7), family_end);
let mut selection = NativeTextSelection::collapsed(family_end);
move_selection(
value,
&mut selection,
NativeTextMovement::Left,
false,
false,
);
assert_eq!(selection, NativeTextSelection::collapsed(combining_end));
move_selection(
value,
&mut selection,
NativeTextMovement::Left,
false,
false,
);
assert_eq!(selection, NativeTextSelection::collapsed(1));
let mut edited = value.to_string();
let mut selection = NativeTextSelection::collapsed(family_end);
let backward = delete_backward(&mut edited, &mut selection);
assert!(backward.text_changed);
assert_eq!(edited, "A\u{65}\u{301}Z");
assert_eq!(selection, NativeTextSelection::collapsed(combining_end));
let forward = delete_forward(&mut edited, &mut selection);
assert!(forward.text_changed);
assert_eq!(edited, "A\u{65}\u{301}");
}
#[cfg(feature = "text-input-core")]
#[test]
fn grapheme_columns_map_back_to_unicode_scalar_indices() {
let value = "x\u{65}\u{301}👩🏽💻z";
assert_eq!(grapheme_count_in_range(value, 0, char_count(value)), 4);
assert_eq!(grapheme_index_for_column(value, 0, char_count(value), 0), 0);
assert_eq!(grapheme_index_for_column(value, 0, char_count(value), 1), 1);
assert_eq!(grapheme_index_for_column(value, 0, char_count(value), 2), 3);
assert_eq!(grapheme_index_for_column(value, 0, char_count(value), 3), 7);
assert_eq!(grapheme_index_for_column(value, 0, char_count(value), 4), 8);
}
#[test]
fn navigation_extends_and_collapses_selection_with_multiline_home_end() {
let value = "ab\n中文\nz";
let mut selection = NativeTextSelection::collapsed(5);
move_selection(value, &mut selection, NativeTextMovement::Home, true, true);
assert_eq!(
selection,
NativeTextSelection {
anchor: 5,
caret: 3
}
);
move_selection(
value,
&mut selection,
NativeTextMovement::Right,
false,
true,
);
assert_eq!(selection, NativeTextSelection::collapsed(5));
move_selection(value, &mut selection, NativeTextMovement::End, false, true);
assert_eq!(selection, NativeTextSelection::collapsed(5));
}
#[test]
fn pointer_selection_preserves_anchor_and_clamps_outside_text() {
let mut selection = NativeTextSelection::collapsed(1);
let extended = set_pointer_selection("A中文", &mut selection, 1, 99);
assert!(extended.handled);
assert!(extended.selection_changed);
assert_eq!(
selection,
NativeTextSelection {
anchor: 1,
caret: 3
}
);
}
#[test]
#[cfg(all(
feature = "text-input-core",
any(
all(target_os = "windows", feature = "windows-win32"),
all(target_os = "macos", feature = "macos-appkit"),
all(
target_os = "linux",
any(feature = "linux-direct-host", feature = "linux-gtk")
)
)
))]
fn native_text_shortcuts_map_case_insensitive_standard_edit_characters() {
assert_eq!(
text_edit_command_for_shortcut_character('A'),
Some(ZsTextEditCommand::SelectAll)
);
assert_eq!(
text_edit_command_for_shortcut_character('c'),
Some(ZsTextEditCommand::Copy)
);
assert_eq!(
text_edit_command_for_shortcut_character('V'),
Some(ZsTextEditCommand::Paste)
);
assert_eq!(
text_edit_command_for_shortcut_character('x'),
Some(ZsTextEditCommand::Cut)
);
assert_eq!(
text_edit_command_for_shortcut_character('Z'),
Some(ZsTextEditCommand::Undo)
);
assert_eq!(text_edit_command_for_shortcut_character('y'), None);
assert_eq!(text_edit_command_for_shortcut_character('中'), None);
}
#[test]
#[cfg(feature = "text-input-core")]
fn typed_edit_commands_share_unicode_selection_clipboard_and_undo_history() {
let widget = WidgetId::new(9);
let mut value = "A中文Z".to_string();
let mut selection = NativeTextSelection {
anchor: 1,
caret: 3,
};
let mut history = NativeTextHistory::default();
let mut clipboard = TestClipboard::default();
let copied = apply_text_edit_command(
ZsTextEditCommand::Copy,
crate::view::ViewTextEditCapabilities::PLAIN,
false,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(copied.clipboard_write);
assert_eq!(clipboard.value, Some(ClipboardData::text("中文")));
let cut = apply_text_edit_command(
ZsTextEditCommand::Cut,
crate::view::ViewTextEditCapabilities::PLAIN,
false,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(cut.text_changed);
assert_eq!(value, "AZ");
assert_eq!(selection, NativeTextSelection::collapsed(1));
let undone = apply_text_edit_command(
ZsTextEditCommand::Undo,
crate::view::ViewTextEditCapabilities::PLAIN,
false,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(undone.undo_applied);
assert_eq!(value, "A中文Z");
assert_eq!(selection.anchor, 1);
assert_eq!(selection.caret, 3);
clipboard.value = Some(ClipboardData::text("🙂"));
let pasted = apply_text_edit_command(
ZsTextEditCommand::Paste,
crate::view::ViewTextEditCapabilities::PLAIN,
false,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(pasted.clipboard_read);
assert_eq!(value, "A🙂Z");
let selected = apply_text_edit_command(
ZsTextEditCommand::SelectAll,
crate::view::ViewTextEditCapabilities::PLAIN,
false,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(selected.selection_changed);
assert_eq!(selection.ordered(), (0, 3));
}
#[test]
#[cfg(feature = "password-box")]
fn protected_edit_policy_pastes_and_undoes_without_copying_or_plain_history() {
let widget = WidgetId::new(10);
let mut value = "vault中".to_string();
let mut selection = NativeTextSelection {
anchor: 0,
caret: value.chars().count(),
};
let mut history = NativeTextHistory::default();
let mut clipboard = TestClipboard {
value: Some(ClipboardData::text("next🙂")),
};
let protected = crate::view::ViewTextEditCapabilities::PROTECTED;
let copied = apply_text_edit_command(
ZsTextEditCommand::Copy,
protected,
true,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(copied.handled);
assert!(!copied.clipboard_write);
assert_eq!(value, "vault中");
let pasted = apply_text_edit_command(
ZsTextEditCommand::Paste,
protected,
true,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(pasted.text_changed);
assert_eq!(value, "next🙂");
assert!(matches!(
history.tracked_value,
Some(NativeTextHistoryValue::Secure(_))
));
assert!(history
.undo
.iter()
.all(|snapshot| matches!(snapshot.value, NativeTextHistoryValue::Secure(_))));
let undone = apply_text_edit_command(
ZsTextEditCommand::Undo,
protected,
true,
widget,
&mut value,
&mut selection,
&mut history,
&mut clipboard,
)
.unwrap();
assert!(undone.undo_applied);
assert_eq!(value, "vault中");
}
}