#![forbid(unsafe_code)]
use std::any::Any;
use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UndoWidgetId(u64);
impl UndoWidgetId {
pub fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(1);
Self(COUNTER.fetch_add(1, Ordering::Relaxed))
}
#[must_use]
pub const fn from_raw(id: u64) -> Self {
Self(id)
}
#[must_use]
pub const fn raw(self) -> u64 {
self.0
}
}
impl Default for UndoWidgetId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for UndoWidgetId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Widget({})", self.0)
}
}
#[derive(Debug, Clone)]
pub enum TextEditOperation {
Insert {
position: usize,
text: String,
},
Delete {
position: usize,
deleted_text: String,
},
Replace {
position: usize,
old_text: String,
new_text: String,
},
SetValue {
old_value: String,
new_value: String,
},
}
impl TextEditOperation {
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Insert { .. } => "Insert text",
Self::Delete { .. } => "Delete text",
Self::Replace { .. } => "Replace text",
Self::SetValue { .. } => "Set value",
}
}
#[must_use]
pub fn size_bytes(&self) -> usize {
std::mem::size_of::<Self>()
+ match self {
Self::Insert { text, .. } => text.len(),
Self::Delete { deleted_text, .. } => deleted_text.len(),
Self::Replace {
old_text, new_text, ..
} => old_text.len() + new_text.len(),
Self::SetValue {
old_value,
new_value,
} => old_value.len() + new_value.len(),
}
}
}
#[derive(Debug, Clone)]
pub enum SelectionOperation {
Changed {
old_anchor: Option<usize>,
old_cursor: usize,
new_anchor: Option<usize>,
new_cursor: usize,
},
}
#[derive(Debug, Clone)]
pub enum TreeOperation {
Expand {
path: Vec<usize>,
},
Collapse {
path: Vec<usize>,
},
ToggleBatch {
expanded: Vec<Vec<usize>>,
collapsed: Vec<Vec<usize>>,
},
}
impl TreeOperation {
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Expand { .. } => "Expand node",
Self::Collapse { .. } => "Collapse node",
Self::ToggleBatch { .. } => "Toggle nodes",
}
}
}
#[derive(Debug, Clone)]
pub enum ListOperation {
Select {
old_selection: Option<usize>,
new_selection: Option<usize>,
},
MultiSelect {
old_selections: Vec<usize>,
new_selections: Vec<usize>,
},
}
impl ListOperation {
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Select { .. } => "Change selection",
Self::MultiSelect { .. } => "Change selections",
}
}
}
#[derive(Debug, Clone)]
pub enum TableOperation {
Sort {
old_column: Option<usize>,
old_ascending: bool,
new_column: Option<usize>,
new_ascending: bool,
},
Filter {
old_filter: String,
new_filter: String,
},
SelectRow {
old_row: Option<usize>,
new_row: Option<usize>,
},
}
impl TableOperation {
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::Sort { .. } => "Change sort",
Self::Filter { .. } => "Apply filter",
Self::SelectRow { .. } => "Select row",
}
}
}
pub type TextEditApplyFn =
Box<dyn Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync>;
pub type TextEditUndoFn =
Box<dyn Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync>;
pub struct WidgetTextEditCmd {
widget_id: UndoWidgetId,
operation: TextEditOperation,
apply_fn: Option<TextEditApplyFn>,
undo_fn: Option<TextEditUndoFn>,
executed: bool,
}
impl WidgetTextEditCmd {
#[must_use]
pub fn new(widget_id: UndoWidgetId, operation: TextEditOperation) -> Self {
Self {
widget_id,
operation,
apply_fn: None,
undo_fn: None,
executed: false,
}
}
#[must_use]
pub fn with_apply<F>(mut self, f: F) -> Self
where
F: Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync + 'static,
{
self.apply_fn = Some(Box::new(f));
self
}
#[must_use]
pub fn with_undo<F>(mut self, f: F) -> Self
where
F: Fn(UndoWidgetId, &TextEditOperation) -> Result<(), String> + Send + Sync + 'static,
{
self.undo_fn = Some(Box::new(f));
self
}
#[must_use]
pub fn widget_id(&self) -> UndoWidgetId {
self.widget_id
}
#[must_use]
pub fn operation(&self) -> &TextEditOperation {
&self.operation
}
}
impl fmt::Debug for WidgetTextEditCmd {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WidgetTextEditCmd")
.field("widget_id", &self.widget_id)
.field("operation", &self.operation)
.field("executed", &self.executed)
.finish()
}
}
impl WidgetTextEditCmd {
#[must_use = "handle the result; errors indicate the edit was not applied"]
pub fn execute(&mut self) -> Result<(), String> {
if let Some(ref apply_fn) = self.apply_fn {
apply_fn(self.widget_id, &self.operation)?;
}
self.executed = true;
Ok(())
}
#[must_use = "handle the result; errors indicate the undo was not applied"]
pub fn undo(&mut self) -> Result<(), String> {
if let Some(ref undo_fn) = self.undo_fn {
undo_fn(self.widget_id, &self.operation)?;
}
self.executed = false;
Ok(())
}
#[must_use = "handle the result; errors indicate the redo was not applied"]
pub fn redo(&mut self) -> Result<(), String> {
self.execute()
}
#[must_use]
pub fn description(&self) -> &'static str {
self.operation.description()
}
#[must_use]
pub fn size_bytes(&self) -> usize {
std::mem::size_of::<Self>() + self.operation.size_bytes()
}
}
pub trait UndoSupport {
fn undo_widget_id(&self) -> UndoWidgetId;
fn create_snapshot(&self) -> Box<dyn Any + Send>;
fn restore_snapshot(&mut self, snapshot: &dyn Any) -> bool;
}
pub trait TextInputUndoExt: UndoSupport {
fn text_value(&self) -> &str;
fn set_text_value(&mut self, value: &str);
fn cursor_position(&self) -> usize;
fn set_cursor_position(&mut self, pos: usize);
fn insert_text_at(&mut self, position: usize, text: &str);
fn delete_text_range(&mut self, start: usize, end: usize);
}
pub trait TreeUndoExt: UndoSupport {
fn is_node_expanded(&self, path: &[usize]) -> bool;
fn expand_node(&mut self, path: &[usize]);
fn collapse_node(&mut self, path: &[usize]);
}
pub trait ListUndoExt: UndoSupport {
fn selected_index(&self) -> Option<usize>;
fn set_selected_index(&mut self, index: Option<usize>);
}
pub trait TableUndoExt: UndoSupport {
fn sort_state(&self) -> (Option<usize>, bool);
fn set_sort_state(&mut self, column: Option<usize>, ascending: bool);
fn filter_text(&self) -> &str;
fn set_filter_text(&mut self, filter: &str);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_undo_widget_id_uniqueness() {
let id1 = UndoWidgetId::new();
let id2 = UndoWidgetId::new();
assert_ne!(id1, id2);
}
#[test]
fn test_undo_widget_id_from_raw() {
let id = UndoWidgetId::from_raw(42);
assert_eq!(id.raw(), 42);
}
#[test]
fn test_text_edit_operation_description() {
assert_eq!(
TextEditOperation::Insert {
position: 0,
text: "x".to_string()
}
.description(),
"Insert text"
);
assert_eq!(
TextEditOperation::Delete {
position: 0,
deleted_text: "x".to_string()
}
.description(),
"Delete text"
);
}
#[test]
fn test_text_edit_operation_size_bytes() {
let op = TextEditOperation::Insert {
position: 0,
text: "hello".to_string(),
};
assert!(op.size_bytes() > 5);
}
#[test]
fn test_widget_text_edit_cmd_creation() {
let widget_id = UndoWidgetId::new();
let cmd = WidgetTextEditCmd::new(
widget_id,
TextEditOperation::Insert {
position: 0,
text: "test".to_string(),
},
);
assert_eq!(cmd.widget_id(), widget_id);
assert_eq!(cmd.description(), "Insert text");
}
#[test]
fn test_widget_text_edit_cmd_with_callbacks() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let applied = Arc::new(AtomicBool::new(false));
let undone = Arc::new(AtomicBool::new(false));
let applied_clone = applied.clone();
let undone_clone = undone.clone();
let widget_id = UndoWidgetId::new();
let mut cmd = WidgetTextEditCmd::new(
widget_id,
TextEditOperation::Insert {
position: 0,
text: "test".to_string(),
},
)
.with_apply(move |_, _| {
applied_clone.store(true, Ordering::SeqCst);
Ok(())
})
.with_undo(move |_, _| {
undone_clone.store(true, Ordering::SeqCst);
Ok(())
});
cmd.execute().unwrap();
assert!(applied.load(Ordering::SeqCst));
cmd.undo().unwrap();
assert!(undone.load(Ordering::SeqCst));
}
#[test]
fn test_tree_operation_description() {
assert_eq!(
TreeOperation::Expand { path: vec![0] }.description(),
"Expand node"
);
assert_eq!(
TreeOperation::Collapse { path: vec![0] }.description(),
"Collapse node"
);
}
#[test]
fn test_list_operation_description() {
assert_eq!(
ListOperation::Select {
old_selection: None,
new_selection: Some(0)
}
.description(),
"Change selection"
);
}
#[test]
fn test_table_operation_description() {
assert_eq!(
TableOperation::Sort {
old_column: None,
old_ascending: true,
new_column: Some(0),
new_ascending: true
}
.description(),
"Change sort"
);
}
#[test]
fn widget_id_display() {
let id = UndoWidgetId::from_raw(7);
assert_eq!(format!("{id}"), "Widget(7)");
}
#[test]
fn widget_id_default_is_unique() {
let a = UndoWidgetId::default();
let b = UndoWidgetId::default();
assert_ne!(a, b);
}
#[test]
fn widget_id_hash_eq() {
let id = UndoWidgetId::from_raw(99);
let id2 = UndoWidgetId::from_raw(99);
assert_eq!(id, id2);
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(id);
assert!(set.contains(&id2));
}
#[test]
fn text_edit_replace_description() {
let op = TextEditOperation::Replace {
position: 0,
old_text: "old".to_string(),
new_text: "new".to_string(),
};
assert_eq!(op.description(), "Replace text");
}
#[test]
fn text_edit_set_value_description() {
let op = TextEditOperation::SetValue {
old_value: "".to_string(),
new_value: "hello".to_string(),
};
assert_eq!(op.description(), "Set value");
}
#[test]
fn text_edit_delete_size_bytes() {
let op = TextEditOperation::Delete {
position: 5,
deleted_text: "abc".to_string(),
};
assert!(op.size_bytes() >= 3);
}
#[test]
fn text_edit_replace_size_bytes() {
let op = TextEditOperation::Replace {
position: 0,
old_text: "aaa".to_string(),
new_text: "bbbbb".to_string(),
};
assert!(op.size_bytes() >= 8); }
#[test]
fn text_edit_set_value_size_bytes() {
let op = TextEditOperation::SetValue {
old_value: "x".to_string(),
new_value: "yyyy".to_string(),
};
assert!(op.size_bytes() >= 5); }
#[test]
fn cmd_execute_without_callbacks_succeeds() {
let mut cmd = WidgetTextEditCmd::new(
UndoWidgetId::from_raw(1),
TextEditOperation::Insert {
position: 0,
text: "hi".to_string(),
},
);
assert!(cmd.execute().is_ok());
}
#[test]
fn cmd_undo_without_callbacks_succeeds() {
let mut cmd = WidgetTextEditCmd::new(
UndoWidgetId::from_raw(1),
TextEditOperation::Delete {
position: 0,
deleted_text: "x".to_string(),
},
);
assert!(cmd.undo().is_ok());
}
#[test]
fn cmd_redo_calls_execute() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let count = Arc::new(AtomicUsize::new(0));
let count_clone = count.clone();
let mut cmd = WidgetTextEditCmd::new(
UndoWidgetId::from_raw(1),
TextEditOperation::Insert {
position: 0,
text: "t".to_string(),
},
)
.with_apply(move |_, _| {
count_clone.fetch_add(1, Ordering::SeqCst);
Ok(())
});
cmd.execute().unwrap();
assert_eq!(count.load(Ordering::SeqCst), 1);
cmd.redo().unwrap();
assert_eq!(count.load(Ordering::SeqCst), 2);
}
#[test]
fn cmd_debug_format() {
let cmd = WidgetTextEditCmd::new(
UndoWidgetId::from_raw(5),
TextEditOperation::Insert {
position: 0,
text: "abc".to_string(),
},
);
let dbg = format!("{cmd:?}");
assert!(dbg.contains("WidgetTextEditCmd"));
assert!(dbg.contains("Insert"));
}
#[test]
fn cmd_size_bytes_nonzero() {
let cmd = WidgetTextEditCmd::new(
UndoWidgetId::from_raw(1),
TextEditOperation::Insert {
position: 0,
text: "hello world".to_string(),
},
);
assert!(cmd.size_bytes() > 11);
}
#[test]
fn tree_toggle_batch_description() {
let op = TreeOperation::ToggleBatch {
expanded: vec![vec![0, 1]],
collapsed: vec![vec![2]],
};
assert_eq!(op.description(), "Toggle nodes");
}
#[test]
fn list_multi_select_description() {
let op = ListOperation::MultiSelect {
old_selections: vec![0, 1],
new_selections: vec![2, 3],
};
assert_eq!(op.description(), "Change selections");
}
#[test]
fn table_filter_description() {
let op = TableOperation::Filter {
old_filter: "".to_string(),
new_filter: "test".to_string(),
};
assert_eq!(op.description(), "Apply filter");
}
#[test]
fn table_select_row_description() {
let op = TableOperation::SelectRow {
old_row: Some(0),
new_row: Some(5),
};
assert_eq!(op.description(), "Select row");
}
#[test]
fn selection_operation_fields() {
let op = SelectionOperation::Changed {
old_anchor: Some(0),
old_cursor: 5,
new_anchor: None,
new_cursor: 10,
};
let dbg = format!("{op:?}");
assert!(dbg.contains("Changed"));
}
}