refactor(oakundo): replace the CHandle vtable layer with owned trait objects
With the C ABI facade (oakengine) retired, the frozen-ABI rationale is gone. UndoCommand now boxes a Send Command trait (new/from_closures/ multi), dropping OakUndoCommandVtable, the userdata trampolines, the refcount shell, the handle module, and all undostack_* handle exports. The global facade loses its raw-pointer out-params (can_undo/can_redo return bool, command_name returns String). oaktimeline/oaknode/ oakplugin/oaktask construct commands directly via UndoCommand::new. oakundo src is now free of unsafe; behavior (ordering, idempotence, done flags, groups, observers, 200-row cap) is unchanged and pinned by the rewritten tests.
This commit is contained in:
@@ -17,10 +17,8 @@
|
||||
//! Free functions replacing the C++ `Node` static methods
|
||||
//! (COVERAGE.md §6/§9).
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use oakcore_rs::TimeRange;
|
||||
use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand};
|
||||
use oakundo::undocommand::UndoCommand;
|
||||
|
||||
use crate::graph::Graph;
|
||||
use crate::id::NodeId;
|
||||
@@ -150,39 +148,6 @@ fn lock_any<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Userdata payload behind a closure-backed undo command: the boxed
|
||||
/// redo/undo closures.
|
||||
struct ClosureCommand {
|
||||
/// The redo closure.
|
||||
redo: Box<dyn FnMut() + Send>,
|
||||
/// The undo closure.
|
||||
undo: Box<dyn FnMut() + Send>,
|
||||
}
|
||||
|
||||
/// `OakUndoCommandVtable` redo thunk for [`ClosureCommand`] userdata.
|
||||
unsafe extern "C" fn closure_redo(ud: *mut c_void) {
|
||||
// SAFETY: `ud` is the `ClosureCommand` box created by
|
||||
// `command_from_closures` and still owned by the command.
|
||||
let c = unsafe { &mut *(ud as *mut ClosureCommand) };
|
||||
(c.redo)();
|
||||
}
|
||||
|
||||
/// `OakUndoCommandVtable` undo thunk for [`ClosureCommand`] userdata.
|
||||
unsafe extern "C" fn closure_undo(ud: *mut c_void) {
|
||||
// SAFETY: see `closure_redo`.
|
||||
let c = unsafe { &mut *(ud as *mut ClosureCommand) };
|
||||
(c.undo)();
|
||||
}
|
||||
|
||||
/// `OakUndoCommandVtable` free thunk for [`ClosureCommand`] userdata.
|
||||
unsafe extern "C" fn closure_free(ud: *mut c_void) {
|
||||
if !ud.is_null() {
|
||||
// SAFETY: the box is destroyed exactly once, by the command that
|
||||
// owns it.
|
||||
unsafe { drop(Box::from_raw(ud as *mut ClosureCommand)) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an un-executed [`UndoCommand`] from redo/undo closures (the
|
||||
/// direct-Rust replacement of the former oakundo bridge
|
||||
/// `command_from_closures`).
|
||||
@@ -190,18 +155,7 @@ fn command_from_closures(
|
||||
redo: impl FnMut() + Send + 'static,
|
||||
undo: impl FnMut() + Send + 'static,
|
||||
) -> UndoCommand {
|
||||
let ud = Box::into_raw(Box::new(ClosureCommand {
|
||||
redo: Box::new(redo),
|
||||
undo: Box::new(undo),
|
||||
}));
|
||||
UndoCommand::from_vtable(
|
||||
OakUndoCommandVtable {
|
||||
redo: Some(closure_redo),
|
||||
undo: Some(closure_undo),
|
||||
free_fn: Some(closure_free),
|
||||
},
|
||||
ud as *mut c_void,
|
||||
)
|
||||
UndoCommand::from_closures(redo, undo)
|
||||
}
|
||||
|
||||
/// Set a keyframed/standard value at a time, returning an un-executed
|
||||
|
||||
@@ -357,50 +357,19 @@ fn serializer_value_codecs() {
|
||||
);
|
||||
}
|
||||
|
||||
/// oakundo `UndoCommand`: vtable commands + multi commands (direct Rust
|
||||
/// oakundo `UndoCommand`: closure commands + multi commands (direct Rust
|
||||
/// calls, single-lib unification).
|
||||
#[test]
|
||||
fn undo_command_roundtrip() {
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand};
|
||||
|
||||
struct Closures {
|
||||
redo: Box<dyn FnMut() + Send>,
|
||||
undo: Box<dyn FnMut() + Send>,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn redo_thunk(ud: *mut c_void) {
|
||||
let c = unsafe { &mut *(ud as *mut Closures) };
|
||||
(c.redo)();
|
||||
}
|
||||
unsafe extern "C" fn undo_thunk(ud: *mut c_void) {
|
||||
let c = unsafe { &mut *(ud as *mut Closures) };
|
||||
(c.undo)();
|
||||
}
|
||||
unsafe extern "C" fn free_thunk(ud: *mut c_void) {
|
||||
if !ud.is_null() {
|
||||
unsafe { drop(Box::from_raw(ud as *mut Closures)) };
|
||||
}
|
||||
}
|
||||
use oakundo::undocommand::UndoCommand;
|
||||
|
||||
fn from_closures(
|
||||
redo: impl FnMut() + Send + 'static,
|
||||
undo: impl FnMut() + Send + 'static,
|
||||
) -> UndoCommand {
|
||||
let ud = Box::into_raw(Box::new(Closures {
|
||||
redo: Box::new(redo),
|
||||
undo: Box::new(undo),
|
||||
}));
|
||||
UndoCommand::from_vtable(
|
||||
OakUndoCommandVtable {
|
||||
redo: Some(redo_thunk),
|
||||
undo: Some(undo_thunk),
|
||||
free_fn: Some(free_thunk),
|
||||
},
|
||||
ud as *mut c_void,
|
||||
)
|
||||
UndoCommand::from_closures(redo, undo)
|
||||
}
|
||||
|
||||
let value = Arc::new(AtomicI32::new(0));
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
//! ## undoable 回写
|
||||
//!
|
||||
//! [`set_input_undoable`]/[`set_input_string_undoable`] 按
|
||||
//! `oaknode::ops::set_value_at_time_command` 的同一 closure-vtable
|
||||
//! `oaknode::ops::set_value_at_time_command` 的同一 closure
|
||||
//! 模式(`command_from_closures`)构造未执行的
|
||||
//! `oakundo::undocommand::UndoCommand`:redo 闭包锁 project、
|
||||
//! `graph.get_mut(id)`、`NodeCore::set_standard_value` 写标准值;
|
||||
@@ -54,7 +54,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
|
||||
use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand};
|
||||
use oakundo::undocommand::UndoCommand;
|
||||
|
||||
/// oaknode 节点引用(single-lib):旧 C ABI 句柄盒
|
||||
/// `(Arc<Mutex<Project>>, NodeId)` 的值型复刻。
|
||||
@@ -331,57 +331,13 @@ fn lock_any<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Userdata payload behind a closure-backed undo command: the boxed
|
||||
/// redo/undo closures(与 `oaknode::ops.rs` 的 `ClosureCommand` 同构;
|
||||
/// 该类型的构造器是 oaknode 私有,桥内自备一份)。
|
||||
struct ClosureCommand {
|
||||
/// The redo closure.
|
||||
redo: Box<dyn FnMut() + Send>,
|
||||
/// The undo closure.
|
||||
undo: Box<dyn FnMut() + Send>,
|
||||
}
|
||||
|
||||
/// `OakUndoCommandVtable` redo thunk for [`ClosureCommand`] userdata.
|
||||
unsafe extern "C" fn closure_redo(ud: *mut std::ffi::c_void) {
|
||||
// SAFETY: `ud` 是 `command_from_closures` 创建的 ClosureCommand
|
||||
// 盒,命令持有其所有权直至 free_fn。
|
||||
let c = unsafe { &mut *(ud as *mut ClosureCommand) };
|
||||
(c.redo)();
|
||||
}
|
||||
|
||||
/// `OakUndoCommandVtable` undo thunk for [`ClosureCommand`] userdata.
|
||||
unsafe extern "C" fn closure_undo(ud: *mut std::ffi::c_void) {
|
||||
// SAFETY: 见 closure_redo。
|
||||
let c = unsafe { &mut *(ud as *mut ClosureCommand) };
|
||||
(c.undo)();
|
||||
}
|
||||
|
||||
/// `OakUndoCommandVtable` free thunk for [`ClosureCommand`] userdata.
|
||||
unsafe extern "C" fn closure_free(ud: *mut std::ffi::c_void) {
|
||||
if !ud.is_null() {
|
||||
// SAFETY: 盒恰被命令析构一次。
|
||||
unsafe { drop(Box::from_raw(ud as *mut ClosureCommand)) };
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 redo/undo 闭包构造未执行的 [`UndoCommand`](closure-vtable
|
||||
/// 模式,与 `oaknode::ops::command_from_closures` 同构)。
|
||||
/// 从 redo/undo 闭包构造未执行的 [`UndoCommand`](closure 模式,与
|
||||
/// `oaknode::ops::command_from_closures` 同构)。
|
||||
fn command_from_closures(
|
||||
redo: impl FnMut() + Send + 'static,
|
||||
undo: impl FnMut() + Send + 'static,
|
||||
) -> UndoCommand {
|
||||
let ud = Box::into_raw(Box::new(ClosureCommand {
|
||||
redo: Box::new(redo),
|
||||
undo: Box::new(undo),
|
||||
}));
|
||||
UndoCommand::from_vtable(
|
||||
OakUndoCommandVtable {
|
||||
redo: Some(closure_redo),
|
||||
undo: Some(closure_undo),
|
||||
free_fn: Some(closure_free),
|
||||
},
|
||||
ud as *mut std::ffi::c_void,
|
||||
)
|
||||
UndoCommand::from_closures(redo, undo)
|
||||
}
|
||||
|
||||
/// 以 undoable 方式设置节点的标准输入值(POD 路径;数值族输入)。
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//! an `oaknode::id::NodeId` in that project's `oaknode::graph::Graph`.
|
||||
//!
|
||||
//! Undo commands follow the oaktimeline `undocommon` pattern: a command
|
||||
//! struct is boxed into an `oakundo::undocommand::UndoCommand` vtable
|
||||
//! command ([`box_command`]); `redo_now`/`undo_now` dispatch to it.
|
||||
//! struct is boxed into an `oakundo::undocommand::UndoCommand` value
|
||||
//! ([`box_command`]); `redo_now`/`undo_now` dispatch to it.
|
||||
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
@@ -40,7 +40,7 @@ use oaknode::node::{NodeBehavior, NodeCore};
|
||||
use oaknode::project::Project;
|
||||
use oaknode::sequence::SequenceBehavior;
|
||||
use oaknode::track::{BlockRange, TrackBehavior, TrackListBehavior, TrackRange, TrackType};
|
||||
use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand};
|
||||
use oakundo::undocommand::UndoCommand;
|
||||
|
||||
/// A project, shared like the C++ `olive::Project` smart pointer.
|
||||
pub type ProjectRef = Arc<Mutex<Project>>;
|
||||
@@ -905,62 +905,13 @@ pub fn pixel_format_from_code(code: i32) -> oakcore_rs::PixelFormat {
|
||||
// Undo commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command trait for the task-local undo commands (mirrors the
|
||||
/// oaktimeline `undocommon::Command` pattern).
|
||||
trait TaskCommand {
|
||||
/// Apply the change.
|
||||
fn redo(&mut self);
|
||||
/// Revert the change.
|
||||
fn undo(&mut self);
|
||||
}
|
||||
/// Command trait for the task-local undo commands (the oakundo command
|
||||
/// trait; mirrors the oaktimeline `undocommon::Command` pattern).
|
||||
pub use oakundo::undocommand::Command as TaskCommand;
|
||||
|
||||
/// Generic `redo` callback forwarding to [`TaskCommand::redo`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `userdata` must be the `Box<T>` produced by [`box_command`].
|
||||
unsafe extern "C" fn redo_cb<T: TaskCommand>(userdata: *mut std::ffi::c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: box_command allocated a `Box<T>`; the command still owns it.
|
||||
(unsafe { &mut *(userdata as *mut T) }).redo();
|
||||
}
|
||||
|
||||
/// Generic `undo` callback forwarding to [`TaskCommand::undo`].
|
||||
///
|
||||
/// # Safety
|
||||
/// As `redo_cb`.
|
||||
unsafe extern "C" fn undo_cb<T: TaskCommand>(userdata: *mut std::ffi::c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as `redo_cb`.
|
||||
(unsafe { &mut *(userdata as *mut T) }).undo();
|
||||
}
|
||||
|
||||
/// Generic `free` callback dropping the boxed command.
|
||||
///
|
||||
/// # Safety
|
||||
/// `userdata` must be the `Box<T>` produced by [`box_command`].
|
||||
unsafe extern "C" fn free_cb<T: TaskCommand>(userdata: *mut std::ffi::c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: the command owns this box; dropping it frees the command.
|
||||
unsafe {
|
||||
drop(Box::from_raw(userdata as *mut T));
|
||||
}
|
||||
}
|
||||
|
||||
/// Box a task command into an oakundo vtable command value.
|
||||
/// Box a task command into an oakundo command value.
|
||||
fn box_command<T: TaskCommand + 'static>(cmd: T) -> UndoCommand {
|
||||
let userdata = Box::into_raw(Box::new(cmd)) as *mut std::ffi::c_void;
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: Some(redo_cb::<T>),
|
||||
undo: Some(undo_cb::<T>),
|
||||
free_fn: Some(free_cb::<T>),
|
||||
};
|
||||
UndoCommand::from_vtable(vtable, userdata)
|
||||
UndoCommand::new(cmd)
|
||||
}
|
||||
|
||||
/// `FolderAddChildCommand` — add an item to a bin folder
|
||||
|
||||
@@ -16,19 +16,20 @@
|
||||
|
||||
//! # oaktimeline — the timeline edit module (Rust)
|
||||
//!
|
||||
//! Reimplements the C++ oaktimeline module (`src/timeline/src`) behind
|
||||
//! its frozen C ABI (`include/timeline/*.h`). See README.md for the
|
||||
//! architectural mapping (per-header domain modules, no UndoCommand
|
||||
//! inheritance → vtable commands).
|
||||
//! Reimplements the C++ oaktimeline module (`src/timeline/src`). See
|
||||
//! README.md for the architectural mapping (per-header domain modules, no
|
||||
//! UndoCommand inheritance → boxed `oakundo` command values).
|
||||
//!
|
||||
//! ## Single-lib unification
|
||||
//!
|
||||
//! The C ABI export layer (`ffi.rs`) and the oaknode/oakundo/oakcommon
|
||||
//! bridge (`bridge/`) were deleted in the single-lib unification: undo
|
||||
//! commands are now `oakundo::undocommand::UndoCommand` values and every
|
||||
//! node/block/track reference is a [`util::NodeRef`] — an
|
||||
//! `Arc<Mutex<oaknode::project::Project>>` + `oaknode::id::NodeId` pair
|
||||
//! that the commands manipulate through the oaknode Rust domain directly.
|
||||
//! commands are now `oakundo::undocommand::UndoCommand` values (the
|
||||
//! crate's command structs implement `undocommon::Command` and are boxed
|
||||
//! as trait objects) and every node/block/track reference is a
|
||||
//! [`util::NodeRef`] — an `Arc<Mutex<oaknode::project::Project>>` +
|
||||
//! `oaknode::id::NodeId` pair that the commands manipulate through the
|
||||
//! oaknode Rust domain directly.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
@@ -244,7 +244,7 @@ fn rational_abs(r: Rational) -> Rational {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker undo commands. Each struct exposes prepare()/redo()/undo(); the
|
||||
// undo stack wraps it through undocommon's vtable (to_command()).
|
||||
// undo stack wraps it through undocommon's box_command (to_command()).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `MarkerAddCommand` (timelinemarker.h).
|
||||
@@ -333,7 +333,7 @@ impl MarkerAddCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command handle.
|
||||
/// Wrap as an oakundo command handle.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -407,7 +407,7 @@ impl MarkerRemoveCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command handle.
|
||||
/// Wrap as an oakundo command handle.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -500,7 +500,7 @@ impl MarkerChangeColorCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command handle.
|
||||
/// Wrap as an oakundo command handle.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -589,7 +589,7 @@ impl MarkerChangeNameCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command handle.
|
||||
/// Wrap as an oakundo command handle.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -685,7 +685,7 @@ impl MarkerChangeTimeCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command handle.
|
||||
/// Wrap as an oakundo command handle.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ impl MultiCamEnableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
@@ -550,7 +550,7 @@ impl MultiCamDisableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
@@ -685,7 +685,7 @@ impl MultiCamSwitchCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
|
||||
@@ -23,28 +23,14 @@
|
||||
//!
|
||||
//! `CHandleCommandWrapper` in C++ subclasses `olive::UndoCommand` to wrap a
|
||||
//! raw `OakUndoCommand`; the Rust equivalent is the crate's own commands
|
||||
//! boxed through [`box_command`], so the wrapper is gone.
|
||||
|
||||
use std::ffi::c_void;
|
||||
//! boxed as trait objects through [`box_command`] (they implement
|
||||
//! [`Command`] = `oakundo::undocommand::Command`), so the wrapper is gone.
|
||||
|
||||
use oaknode::graph::NodeEntry;
|
||||
use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand};
|
||||
use oakundo::undocommand::UndoCommand;
|
||||
|
||||
use crate::util::{block_add_to_graph, block_remove_from_graph, NodeRef};
|
||||
|
||||
/// An empty (all callbacks absent) command — the failure-path result of
|
||||
/// the deleted oaknode remove-command factory (an empty command handle).
|
||||
pub(crate) fn empty_command() -> UndoCommand {
|
||||
UndoCommand::from_vtable(
|
||||
OakUndoCommandVtable {
|
||||
redo: None,
|
||||
undo: None,
|
||||
free_fn: None,
|
||||
},
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `oaknode_command_create_remove_node` — a command that detaches a node
|
||||
/// from the project graph on `redo` (the detached entry is owned by this
|
||||
/// command) and re-inserts it, identity-preserving, on `undo`. The C++
|
||||
@@ -114,83 +100,23 @@ pub fn create_and_run_block_remove_command(block: &NodeRef) -> UndoCommand {
|
||||
create_and_run_remove_command(block)
|
||||
}
|
||||
|
||||
/// `free_command_handle`: release and null a command; NULL no-op. With the
|
||||
/// `UndoCommand` value type, "freeing" is overwriting the pointee with an
|
||||
/// empty command (the old value drops, running its `free_fn`).
|
||||
pub fn free_command_handle(command: *mut UndoCommand) {
|
||||
if command.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: the caller passes a valid pointer; overwriting drops the old
|
||||
// value and installs the no-op command.
|
||||
unsafe { *command = empty_command() };
|
||||
}
|
||||
/// Trait implemented by every timeline undo command (re-export of the
|
||||
/// oakundo command trait). The crate's commands are boxed into an
|
||||
/// [`UndoCommand`] via [`box_command`]; the command's
|
||||
/// `redo_now`/`undo_now` dispatch to these callbacks.
|
||||
pub use oakundo::undocommand::Command;
|
||||
|
||||
/// Trait implemented by every timeline undo command. The crate's commands
|
||||
/// are boxed into an oakundo vtable command via [`box_command`]; the
|
||||
/// command's `redo_now`/`undo_now` dispatch to these callbacks.
|
||||
pub trait Command {
|
||||
/// Apply the change.
|
||||
fn redo(&mut self);
|
||||
/// Revert the change.
|
||||
fn undo(&mut self);
|
||||
}
|
||||
|
||||
/// Generic `redo` callback forwarding to [`Command::redo`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `userdata` must be the `Box<T>` produced by [`box_command`].
|
||||
unsafe extern "C" fn redo_cb<T: Command>(userdata: *mut c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: box_command allocated a `Box<T>`; still alive because the
|
||||
// command owns it.
|
||||
(unsafe { &mut *(userdata as *mut T) }).redo();
|
||||
}
|
||||
|
||||
/// Generic `undo` callback forwarding to [`Command::undo`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `userdata` must be the `Box<T>` produced by [`box_command`].
|
||||
unsafe extern "C" fn undo_cb<T: Command>(userdata: *mut c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as `redo_cb`.
|
||||
(unsafe { &mut *(userdata as *mut T) }).undo();
|
||||
}
|
||||
|
||||
/// Generic `free` callback dropping the boxed command.
|
||||
///
|
||||
/// # Safety
|
||||
/// `userdata` must be the `Box<T>` produced by [`box_command`].
|
||||
unsafe extern "C" fn free_cb<T: Command>(userdata: *mut c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: the command owns this box; dropping it frees the command.
|
||||
unsafe { drop(Box::from_raw(userdata as *mut T)) };
|
||||
}
|
||||
|
||||
/// Box a command into an oakundo vtable command value. The command owns
|
||||
/// the boxed `T`; `redo_now`/`undo_now` dispatch to `T::redo`/`T::undo`,
|
||||
/// and dropping the command drops `T`.
|
||||
/// Box a command into an oakundo command value. The command owns the
|
||||
/// boxed `T`; `redo_now`/`undo_now` dispatch to `T::redo`/`T::undo`, and
|
||||
/// dropping the command drops `T`.
|
||||
pub(crate) fn box_command<T: Command + 'static>(cmd: T) -> UndoCommand {
|
||||
let userdata = Box::into_raw(Box::new(cmd)) as *mut c_void;
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: Some(redo_cb::<T>),
|
||||
undo: Some(undo_cb::<T>),
|
||||
free_fn: Some(free_cb::<T>),
|
||||
};
|
||||
// `from_vtable` copies the vtable and takes ownership of `userdata`.
|
||||
UndoCommand::from_vtable(vtable, userdata)
|
||||
UndoCommand::new(cmd)
|
||||
}
|
||||
|
||||
/// `MultiUndoCommand` — a command that runs several child commands in order
|
||||
/// on `redo` and in reverse on `undo` (timelineundocommon.h
|
||||
/// `MultiUndoCommand`). Children are boxed `Command`s; the whole group wraps
|
||||
/// into a single oakundo vtable command via [`box_command`].
|
||||
/// into a single oakundo command via [`box_command`].
|
||||
pub struct MultiUndoCommand {
|
||||
/// Child commands, run in order on `redo`.
|
||||
commands: Vec<Box<dyn Command>>,
|
||||
@@ -214,7 +140,7 @@ impl MultiUndoCommand {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ impl BlockResizeCommand {
|
||||
block_set_length_and_media_out(&self.block, self.old_length);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ impl BlockResizeWithMediaInCommand {
|
||||
block_set_length_and_media_in(&self.block, self.old_length);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ impl BlockSetMediaInCommand {
|
||||
clip_set_media_in(&self.block, self.old_media_in);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -325,7 +325,7 @@ impl TimelineAddTrackCommand {
|
||||
c.track()
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -517,7 +517,7 @@ impl TimelineRemoveTrackCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -608,7 +608,7 @@ impl TransitionRemoveCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -833,7 +833,7 @@ impl TrackReplaceBlockWithGapCommand {
|
||||
.push(TransitionRemoveCommand::new(relevant, true));
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -885,7 +885,7 @@ impl BlockEnableDisableCommand {
|
||||
block_set_enabled(&self.block, self.old_enabled);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -1073,7 +1073,7 @@ impl TrackListInsertGaps {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -1195,7 +1195,7 @@ impl TimelineAddDefaultTransitionCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ impl BlockTrimCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -517,7 +517,7 @@ impl TrackSlideCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -672,7 +672,7 @@ impl TrackPlaceBlockCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -740,7 +740,7 @@ impl TrackMoveBlockCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ pub struct TrackRippleRemoveAreaCommand {
|
||||
track: NodeRef,
|
||||
/// Area to clear.
|
||||
range: TimeRange,
|
||||
/// Whether `prepare` has run (the vtable command path never calls
|
||||
/// Whether `prepare` has run (the oakundo command path never calls
|
||||
/// `prepare()` itself, so `redo` derives the operations on first use).
|
||||
prepared: bool,
|
||||
/// Out-point trim on the first block (`timelineundoripple.h` `trim_out_`).
|
||||
@@ -215,7 +215,7 @@ impl TrackRippleRemoveAreaCommand {
|
||||
///
|
||||
/// Mirrors the C++ algorithm (`oaknode_track_get_nearest_block_before_or_at`
|
||||
/// is a direct oaknode-domain query; `redo` invokes this on first use
|
||||
/// because the vtable command path never calls `prepare` itself).
|
||||
/// because the oakundo command path never calls `prepare` itself).
|
||||
pub fn prepare(&mut self) {
|
||||
// Idempotent: recompute from the current track state, discarding any
|
||||
// previously derived operations.
|
||||
@@ -324,7 +324,7 @@ impl TrackRippleRemoveAreaCommand {
|
||||
|
||||
/// `redo`: apply the ripple removal.
|
||||
pub fn redo(&mut self) {
|
||||
// The vtable command path never invokes `prepare()` (the oakundo
|
||||
// The oakundo command path never invokes `prepare()` (the oakundo
|
||||
// wrapper only dispatches redo/undo), so derive the operations
|
||||
// on first use.
|
||||
if !self.prepared {
|
||||
@@ -408,7 +408,7 @@ impl TrackRippleRemoveAreaCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -489,7 +489,7 @@ impl TrackListRippleRemoveAreaCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -555,7 +555,7 @@ impl TimelineRippleRemoveAreaCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -763,7 +763,7 @@ impl TrackListRippleToolCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -854,7 +854,7 @@ impl TimelineRippleDeleteGapsAtRegionsCommand {
|
||||
///
|
||||
/// The gap-kind, nearest-block, sequence-track and locked-flag queries
|
||||
/// all go through the oaknode domain via `crate::util`; `redo` invokes
|
||||
/// this on first use because the oakundo vtable wrapper only dispatches
|
||||
/// this on first use because the oakundo command wrapper only dispatches
|
||||
/// `redo`/`undo`.
|
||||
pub fn prepare(&mut self) {
|
||||
self.commands_.clear();
|
||||
@@ -1025,7 +1025,7 @@ impl TimelineRippleDeleteGapsAtRegionsCommand {
|
||||
|
||||
/// `redo`: apply the gap deletions.
|
||||
///
|
||||
/// The vtable command path never invokes `prepare()` (the oakundo vtable
|
||||
/// The oakundo command path never invokes `prepare()` (the oakundo
|
||||
/// wrapper only dispatches `redo`/`undo`), so the sub-commands are built
|
||||
/// on first use; later redos re-apply the stored commands.
|
||||
pub fn redo(&mut self) {
|
||||
@@ -1044,7 +1044,7 @@ impl TimelineRippleDeleteGapsAtRegionsCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ impl BlockSplitCommand {
|
||||
/// original span, so the out point is preserved exactly).
|
||||
pub fn redo(&mut self) {
|
||||
// Create the second half if redo is invoked without a preceding
|
||||
// prepare() (the vtable command path may call redo directly).
|
||||
// prepare() (the oakundo command path may call redo directly).
|
||||
self.prepare();
|
||||
// Re-attach the copied subgraph if a previous undo detached it.
|
||||
self.re_attach_subgraph();
|
||||
@@ -250,7 +250,7 @@ impl BlockSplitCommand {
|
||||
self.new_block.clone()
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
@@ -343,7 +343,7 @@ impl BlockSplitPreservingLinksCommand {
|
||||
|
||||
/// `redo`: redo every child command in order.
|
||||
///
|
||||
/// The vtable command path never invokes `prepare()` (the oakundo
|
||||
/// The oakundo command path never invokes `prepare()` (the oakundo
|
||||
/// wrapper only dispatches `redo`/`undo`), so the children are built
|
||||
/// on first redo; `prepare` itself redoes each child as it builds it,
|
||||
/// so the first redo has nothing left to run. Later redos (after an
|
||||
@@ -385,7 +385,7 @@ impl BlockSplitPreservingLinksCommand {
|
||||
None
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
@@ -466,7 +466,7 @@ impl TrackSplitAtTimeCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ impl TrackRippleRemoveBlockCommand {
|
||||
track_insert_block_after(&self.track, &self.block, self.before.as_ref());
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ impl TrackPrependBlockCommand {
|
||||
track_ripple_remove_block(&self.track, &self.block);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ impl TrackInsertBlockAfterCommand {
|
||||
track_ripple_remove_block(&self.track, &self.block);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ impl TrackReplaceBlockCommand {
|
||||
track_replace_block(&self.track, &self.replace, &self.old);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command value.
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ impl WorkareaSetEnabledCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command handle.
|
||||
/// Wrap as an oakundo command handle.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
@@ -256,7 +256,7 @@ impl WorkareaSetRangeCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo vtable command handle.
|
||||
/// Wrap as an oakundo command handle.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
crate::undocommon::box_command(self)
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ fn move_block_round_trip() {
|
||||
assert_eq!(span_of(&tail), Some((Rational::new(50, 1), Rational::new(100, 1))));
|
||||
}
|
||||
|
||||
/// The `Command` trait dispatch (used by the oakundo vtable wrappers)
|
||||
/// The `Command` trait dispatch (used by the oakundo command values)
|
||||
/// routes through the same redo/undo bodies as the inherent methods.
|
||||
#[test]
|
||||
fn commands_trait_dispatch() {
|
||||
|
||||
@@ -266,7 +266,7 @@ fn marker_remove_command_double_redo() {
|
||||
}
|
||||
|
||||
/// Every marker command dispatches through the `Command` trait, which is
|
||||
/// how the undo stack's vtable invokes them.
|
||||
/// how the undo stack invokes them.
|
||||
#[test]
|
||||
fn marker_commands_trait_dispatch() {
|
||||
let list_h = make_owned(TimelineMarkerList::new());
|
||||
|
||||
@@ -153,7 +153,7 @@ fn workarea_commands_box_to_undo_command() {
|
||||
}
|
||||
|
||||
/// `Command` trait dispatch routes through the same redo/undo bodies as
|
||||
/// the inherent methods (used by the undo stack vtable).
|
||||
/// the inherent methods (used by the undo stack command values).
|
||||
#[test]
|
||||
fn workarea_commands_trait_dispatch() {
|
||||
let wa_h = make_owned(TimelineWorkArea::new());
|
||||
|
||||
@@ -6,8 +6,7 @@ description = "Oak Video Editor undo/redo history module (Rust)"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
oakcore-rs = { path = "../oakcore" }
|
||||
thiserror = "2"
|
||||
|
||||
+33
-29
@@ -1,33 +1,36 @@
|
||||
# oakundo Rust crate
|
||||
|
||||
> Status: **implemented**. Ports the C++ oakundo module
|
||||
> (`src/undo/src`) to Rust behind its frozen C ABI
|
||||
> (`include/undo/*.h`). Template follows `crates/oakplugin`.
|
||||
> (`src/undo/src`) to Rust. Template follows `crates/oakplugin`.
|
||||
|
||||
## Scope
|
||||
|
||||
Replaces the C++ oakundo module (`src/undo/src`): undoable commands
|
||||
and the undo/redo history stack. Public contract: `include/undo/*.h`
|
||||
(3 headers: `error.h`, `undocommand.h`, `undostack.h`) — frozen,
|
||||
implemented verbatim by `src/ffi.rs`.
|
||||
and the undo/redo history stack. The frozen C ABI (`include/undo/*.h`)
|
||||
and the engine facade that consumed it are gone (see the root
|
||||
`Cargo.toml` note on `crates/oakengine.bk`): every consumer links the
|
||||
crate as a plain rlib and uses the value-typed API below.
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
1. **Vtable-command pattern is the centerpiece.** In C++ other modules
|
||||
*subclass* `olive::UndoCommand` (`redo()`/`undo()` overrides) and
|
||||
plug themselves in polymorphically. Rust has no inheritance, so the
|
||||
C ABI already models exactly this with
|
||||
`OakUndoCommandVtable { redo, undo, free_fn }` plus a caller-owned
|
||||
`userdata` pointer. The safe layer's [`undocommand::CommandKind`]
|
||||
is the direct analog: either a caller-defined vtable command
|
||||
(function pointers + userdata) or a [`undocommand::MultiUndoCommand`]
|
||||
composite. Domain logic dispatches on the vtable the same way the
|
||||
C++ virtual dispatch does.
|
||||
2. **Modified-state callbacks are intentionally not part of the C ABI.**
|
||||
The C++ `UndoCommand::redo_and_set_modified` pair records/restores a
|
||||
project dirty flag via `std::function` accessors. The public headers
|
||||
expose none of this; the stack drives state via `done_` on the safe
|
||||
type instead, and the flag callbacks are left as a documented future
|
||||
1. **Trait-object commands replace the vtable pattern.** In C++ other
|
||||
modules *subclass* `olive::UndoCommand` (`redo()`/`undo()` overrides)
|
||||
and plug themselves in polymorphically. Rust models the same
|
||||
polymorphism with a boxed [`undocommand::Command`] trait object:
|
||||
one-off edits arrive as closure commands
|
||||
([`undocommand::UndoCommand::from_closures`]) and whole-struct
|
||||
commands implement the trait and are boxed with
|
||||
[`undocommand::UndoCommand::new`]; composites are
|
||||
[`undocommand::MultiUndoCommand`]. The former
|
||||
`OakUndoCommandVtable` callback table, its `extern "C"` trampolines
|
||||
and the refcounted `CHandle` layer were deleted with the C ABI —
|
||||
domain logic dispatches through the trait the same way C++ virtual
|
||||
dispatch does.
|
||||
2. **Modified-state callbacks are intentionally omitted.** The C++
|
||||
`UndoCommand::redo_and_set_modified` pair records/restores a project
|
||||
dirty flag via `std::function` accessors. The public headers exposed
|
||||
none of this; the stack drives state via `done_` on the safe type
|
||||
instead, and the flag callbacks are left as a documented future
|
||||
extension.
|
||||
3. **`UndoStack` state machine** is modeled directly on the C++:
|
||||
two deques — `commands_` (done, oldest at front) and
|
||||
@@ -36,25 +39,26 @@ implemented verbatim by `src/ffi.rs`.
|
||||
(200) is exceeded; `jump` clamps and walks via `undo`/`redo`. The
|
||||
fresh stack holds a single "New/Open Project" empty command so
|
||||
`can_undo` is false at the bottom (per `undostack.cpp`).
|
||||
4. **No merge semantics.** `include/undo/*.h` and `src/undo/src/*`
|
||||
define no `merge_with`/`can_merge`; commands are never coalesced.
|
||||
Tests reflect this (no merge tests).
|
||||
4. **No merge semantics.** `src/undo/src/*` defines no
|
||||
`merge_with`/`can_merge`; commands are never coalesced. Tests
|
||||
reflect this (no merge tests).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
lib.rs crate doc + module map
|
||||
error.rs error codes (include/undo/error.h)
|
||||
handle.rs refcounted-handle scaffolding (OAKUNDO_ABI_VERSION=1)
|
||||
undocommand.rs UndoCommand / vtable command / MultiUndoCommand
|
||||
error.rs error codes (mirrors include/undo/error.h values)
|
||||
undocommand.rs UndoCommand / Command trait / MultiUndoCommand
|
||||
undostack.rs UndoStack + empty bottom command
|
||||
ffi.rs export layer (one submodule per public header)
|
||||
global.rs process-wide stack, groups, observers
|
||||
tests/ contract tests per module
|
||||
```
|
||||
|
||||
`error.h` exports macros only and is folded into `ffi.rs`'s preamble
|
||||
(no own submodule), matching the codec crate convention.
|
||||
The module has no `unsafe` code and no `extern "C"` surface; panics in
|
||||
command callbacks propagate as normal process-internal panics (the
|
||||
process-wide stack recovers a poisoned mutex the same way the former
|
||||
`guard*` FFI wrappers did).
|
||||
|
||||
## Dependency policy
|
||||
|
||||
|
||||
+63
-200
@@ -15,31 +15,27 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The process-wide undo stack, undo groups and the command-success
|
||||
//! observer hook (M14 R1: sunk from the engine facade's `undo.rs`).
|
||||
//! observer hook.
|
||||
//!
|
||||
//! The facade used to own the process-wide stack (the module-00 analogue
|
||||
//! of `EngineCore::undo_stack()`), the open undo group and the
|
||||
//! write-through notification as facade state; all of it is process state,
|
||||
//! so this module holds it and the facade forwards. The observer registry
|
||||
//! The retired engine facade used to own the process-wide stack (the
|
||||
//! module-00 analogue of `EngineCore::undo_stack()`), the open undo group
|
||||
//! and the write-through notification as facade state; all of it is
|
||||
//! process state, so this module holds it directly. The observer registry
|
||||
//! lets downstream modules (the oakstorage write-through session manager)
|
||||
//! subscribe to "a command was recorded" notifications without a facade
|
||||
//! round-trip.
|
||||
//! subscribe to "a command was recorded" notifications.
|
||||
//!
|
||||
//! M14 R5: everything in this module is Rust-typed — the process-wide
|
||||
//! stack is a plain `static Mutex<UndoStack>` and the open group holds an
|
||||
//! [`UndoCommand`] value. The only remaining `CHandle` is the
|
||||
//! [`push_or_run`] facade entry, which converts the incoming module
|
||||
//! command handle into an [`UndoCommand`] value at the boundary.
|
||||
//! Everything here is Rust-typed: the process-wide stack is a plain
|
||||
//! `static Mutex<UndoStack>` and the open group holds an
|
||||
//! [`UndoCommand`] value. The former `CHandle`-marshalling entry point
|
||||
//! (`push_or_run`) and the raw-pointer out-parameter queries
|
||||
//! (`can_undo(out)` / `command_text(buf, size)` / `command_is_done(out)`)
|
||||
//! were deleted with the C ABI — the same operations are exposed as
|
||||
//! value-typed functions below.
|
||||
|
||||
use std::ffi::{c_char, c_int, c_void};
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::error::{Error, OAKUNDO_E_FAILED, Result};
|
||||
use crate::handle::CHandle;
|
||||
use crate::undocommand::{
|
||||
command_free, command_from_owned, command_redo_now, command_take, UndoCommand,
|
||||
};
|
||||
use crate::error::Result;
|
||||
use crate::undocommand::UndoCommand;
|
||||
use crate::undostack::UndoStack;
|
||||
|
||||
/// The process-wide undo stack, created lazily on first use and kept for
|
||||
@@ -49,13 +45,6 @@ fn global_stack() -> &'static Mutex<UndoStack> {
|
||||
STACK.get_or_init(|| Mutex::new(UndoStack::new()))
|
||||
}
|
||||
|
||||
/// Stable opaque token for the engine's `oakengine_undo_handle` export:
|
||||
/// the stack's address (never dereferenced by callers; lives for the
|
||||
/// process).
|
||||
pub fn stack_token() -> *mut c_void {
|
||||
global_stack() as *const Mutex<UndoStack> as *mut c_void
|
||||
}
|
||||
|
||||
/// Run `f` on the process-wide stack; a poisoned mutex is recovered (its
|
||||
/// inner value is still valid).
|
||||
fn with_stack<R>(f: impl FnOnce(&mut UndoStack) -> Result<R>) -> Result<R> {
|
||||
@@ -121,7 +110,7 @@ fn group_lock() -> std::sync::MutexGuard<'static, Option<OpenGroup>> {
|
||||
pub fn group_begin(name: &str) -> Result<()> {
|
||||
let mut g = group_lock();
|
||||
if g.is_some() {
|
||||
return Err(Error::State);
|
||||
return Err(crate::error::Error::State);
|
||||
}
|
||||
*g = Some(OpenGroup {
|
||||
multi: UndoCommand::multi(),
|
||||
@@ -135,7 +124,7 @@ pub fn group_begin(name: &str) -> Result<()> {
|
||||
/// when no group is open.
|
||||
pub fn group_end() -> Result<()> {
|
||||
let mut g = group_lock();
|
||||
let open = g.take().ok_or(Error::State)?;
|
||||
let open = g.take().ok_or(crate::error::Error::State)?;
|
||||
let multi = open.multi;
|
||||
let name = open.name;
|
||||
drop(g);
|
||||
@@ -155,7 +144,7 @@ pub fn group_end() -> Result<()> {
|
||||
/// no group is open. No observers fire (nothing was recorded).
|
||||
pub fn group_abort() -> Result<()> {
|
||||
let mut g = group_lock();
|
||||
let open = g.take().ok_or(Error::State)?;
|
||||
let open = g.take().ok_or(crate::error::Error::State)?;
|
||||
let mut multi = open.multi;
|
||||
drop(g);
|
||||
// The multi command itself is never marked done (each child was
|
||||
@@ -170,79 +159,29 @@ pub fn group_abort() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `command` onto the stack and execute its redo (or add it to the
|
||||
/// open group). `command` is consumed: the stack/multi takes the command
|
||||
/// value, leaving the caller's handle as a non-owning shell that still
|
||||
/// needs its own release. Returns 0 on success, a module error code
|
||||
/// otherwise. Command observers fire only when the command was recorded on
|
||||
/// the STACK — a group child joins the group, and the group itself
|
||||
/// notifies at [`group_end`].
|
||||
pub fn push_or_run(command: CHandle, name: &str) -> c_int {
|
||||
/// Push `command` onto the process-wide stack (redo then record), or into
|
||||
/// the open group as an already-done child. On a stack record the command
|
||||
/// observers fire (the oakstorage write-through persists the edit).
|
||||
pub fn push(command: UndoCommand, name: &str) -> Result<()> {
|
||||
let mut g = group_lock();
|
||||
if let Some(group) = g.as_mut() {
|
||||
// The group takes the command value (command_take), so the eager
|
||||
// redo must happen on the still-owned handle FIRST — the group
|
||||
// receives an already-done command (C++ semantics: add_child +
|
||||
// redo_now, net effect identical for the group's reverse-order
|
||||
// undo).
|
||||
let rc = command_redo_now(command);
|
||||
if rc != 0 {
|
||||
return rc;
|
||||
}
|
||||
// SAFETY: `command` is a live module command handle (facade
|
||||
// contract); `command_take` moves the value out of its box and
|
||||
// marks the shell non-owning.
|
||||
return match unsafe { command_take(command.ctx) } {
|
||||
Ok(cmd) => {
|
||||
group.multi.multi_add_child(cmd);
|
||||
0
|
||||
}
|
||||
Err(e) => e.code(),
|
||||
};
|
||||
// The group takes the command as an already-done child: the eager
|
||||
// redo must run BEFORE the child joins the group (C++ semantics:
|
||||
// add_child + redo_now, net effect identical for the group's
|
||||
// reverse-order undo).
|
||||
let mut command = command;
|
||||
command.redo_now();
|
||||
group.multi.multi_add_child(command);
|
||||
return Ok(());
|
||||
}
|
||||
// No group open: take the command value and push it onto the
|
||||
// process-wide stack (redo then record).
|
||||
// SAFETY: as above, `command` is a live module command handle.
|
||||
let cmd = match unsafe { command_take(command.ctx) } {
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => return e.code(),
|
||||
};
|
||||
drop(g);
|
||||
let mut guard = global_stack().lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.push(cmd, name);
|
||||
guard.push(command, name);
|
||||
drop(guard);
|
||||
// The stack took the command; its redo already ran (plan M13 D2):
|
||||
// persist the write-through subscribers.
|
||||
// The stack took the command; its redo already ran: persist the
|
||||
// write-through subscribers.
|
||||
notify_observers();
|
||||
0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe value-typed surface (M14 R3)
|
||||
//
|
||||
// The direct-rlib frontends (the app) hold module [`UndoCommand`] values,
|
||||
// not CHandles. The functions below are the safe twins of the handle-based
|
||||
// API above; the handle marshalling they perform stays inside this crate.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Push `command` onto the process-wide stack (redo then record), or into
|
||||
/// the open group. On a stack record the command observers fire (the
|
||||
/// oakstorage write-through persists the edit).
|
||||
pub fn push(command: UndoCommand, name: &str) -> Result<()> {
|
||||
// SAFETY: `command_from_owned` boxes the value; `push_or_run` takes the
|
||||
// value out of the handle (stack push or group child), and
|
||||
// `command_free` releases the remaining non-owning shell (dropping the
|
||||
// command itself when nobody took it).
|
||||
let mut handle = unsafe { command_from_owned(command) };
|
||||
if handle.is_null() {
|
||||
return Err(Error::NoMem);
|
||||
}
|
||||
let rc = push_or_run(handle, name);
|
||||
unsafe{command_free(&mut handle)};
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::from_code(rc))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step the process-wide stack back one entry (no-op at the bottom). On
|
||||
@@ -261,14 +200,12 @@ pub fn redo() -> Result<()> {
|
||||
|
||||
/// Whether the process-wide stack has an entry to undo.
|
||||
pub fn undoable() -> bool {
|
||||
let mut v: c_int = 0;
|
||||
unsafe{can_undo(&mut v).is_ok() && v != 0}
|
||||
can_undo()
|
||||
}
|
||||
|
||||
/// Whether the process-wide stack has an entry to redo.
|
||||
pub fn redoable() -> bool {
|
||||
let mut v: c_int = 0;
|
||||
unsafe{can_redo(&mut v).is_ok() && v != 0}
|
||||
can_redo()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -285,30 +222,14 @@ pub fn index() -> Result<i64> {
|
||||
with_stack(|s| Ok(s.done_count()))
|
||||
}
|
||||
|
||||
/// Whether an undo is possible (1/0 via `out_value`; a module error code
|
||||
/// otherwise).
|
||||
pub unsafe fn can_undo(out_value: *mut c_int) -> Result<()> {
|
||||
if out_value.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let value = with_stack(|s| Ok(if s.can_undo() { 1 } else { 0 }))?;
|
||||
// SAFETY: `out_value` points to at least one writable `c_int`, per the
|
||||
// facade contract.
|
||||
unsafe { *out_value = value };
|
||||
Ok(())
|
||||
/// Whether an undo is possible.
|
||||
pub fn can_undo() -> bool {
|
||||
with_stack(|s| Ok(s.can_undo())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a redo is possible (1/0 via `out_value`; a module error code
|
||||
/// otherwise).
|
||||
pub unsafe fn can_redo(out_value: *mut c_int) -> Result<()> {
|
||||
if out_value.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let value = with_stack(|s| Ok(if s.can_redo() { 1 } else { 0 }))?;
|
||||
// SAFETY: `out_value` points to at least one writable `c_int`, per the
|
||||
// facade contract.
|
||||
unsafe { *out_value = value };
|
||||
Ok(())
|
||||
/// Whether a redo is possible.
|
||||
pub fn can_redo() -> bool {
|
||||
with_stack(|s| Ok(s.can_redo())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Undo/redo until the done-command count equals `index`. On success the
|
||||
@@ -331,58 +252,15 @@ pub fn clear() -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Two-stage label getter for the row at `row` (see
|
||||
/// [`crate::undostack::undostack_command_text`]): returns the required
|
||||
/// size including the NUL, or a module error code.
|
||||
pub unsafe fn command_text(row: i64, buf: *mut c_char, buf_size: c_int) -> c_int {
|
||||
let result = catch_unwind(AssertUnwindSafe(|| -> Result<i32> {
|
||||
with_stack(|s| {
|
||||
if row < 0 || row >= s.command_count() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let name = s.command_name(row)?;
|
||||
let required = (name.len() + 1) as i32;
|
||||
if !buf.is_null() && buf_size > 0 {
|
||||
let copy_len = name.len().min((buf_size as usize).saturating_sub(1));
|
||||
let bytes = name.as_bytes();
|
||||
// SAFETY: `buf` points to `buf_size` writable bytes and we
|
||||
// write at most `copy_len` (+ one NUL) of them.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy_len);
|
||||
*buf.add(copy_len) = 0;
|
||||
}
|
||||
}
|
||||
Ok(required)
|
||||
})
|
||||
}));
|
||||
match result {
|
||||
Ok(Ok(required)) => required,
|
||||
Ok(Err(e)) => e.code(),
|
||||
Err(_) => OAKUNDO_E_FAILED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the row at `row` is done (1/0 via `out_value`; a module error
|
||||
/// code otherwise — `-20004` for an out-of-range row).
|
||||
pub unsafe fn command_is_done(row: i64, out_value: *mut c_int) -> Result<()> {
|
||||
if out_value.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let done = with_stack(|s| s.command_is_done(row))?;
|
||||
// SAFETY: `out_value` points to at least one writable `c_int`, per the
|
||||
// facade contract.
|
||||
unsafe { *out_value = if done { 1 } else { 0 } };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The user-visible label of the row at `row` (the safe twin of the
|
||||
/// two-stage [`command_text`]; the history panel's row query).
|
||||
/// The user-visible label of the row at `row` (the safe replacement for
|
||||
/// the C-ABI two-stage `command_text(buf, size)`; the history panel's row
|
||||
/// query). `NotFound` for an out-of-range row.
|
||||
pub fn command_name(row: i64) -> Result<String> {
|
||||
with_stack(|s| s.command_name(row).map(|n| n.to_string()))
|
||||
}
|
||||
|
||||
/// Whether the row at `row` is done (the safe twin of
|
||||
/// [`command_is_done`]; the history panel's gray-row query).
|
||||
/// Whether the row at `row` is done (the safe replacement for the C-ABI
|
||||
/// `command_is_done(out)`; the history panel's gray-row query).
|
||||
pub fn command_done(row: i64) -> Result<bool> {
|
||||
with_stack(|s| s.command_is_done(row))
|
||||
}
|
||||
@@ -405,21 +283,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The stack/group/observer state is process-wide: every test here runs
|
||||
/// serially under this lock, mirroring the facade's GLOBAL_STACK_LOCK
|
||||
/// pattern.
|
||||
/// serially under this lock.
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn vtable_command() -> CHandle {
|
||||
use crate::undocommand::{command_init, OakUndoCommandVtable};
|
||||
unsafe{
|
||||
command_init(
|
||||
&OakUndoCommandVtable {
|
||||
redo: None,
|
||||
undo: None,
|
||||
free_fn: None,
|
||||
},
|
||||
std::ptr::null_mut(),
|
||||
)}
|
||||
/// A no-op command value.
|
||||
fn noop_command() -> UndoCommand {
|
||||
UndoCommand::from_closures(|| {}, || {})
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -431,23 +300,18 @@ mod tests {
|
||||
assert!(clear().is_ok());
|
||||
assert_eq!(count().unwrap(), 1);
|
||||
assert_eq!(index().unwrap(), 1);
|
||||
let mut v: c_int = 1;
|
||||
assert!(unsafe{can_undo(&mut v).is_ok()});
|
||||
assert_eq!(v, 0);
|
||||
assert!(!can_undo());
|
||||
|
||||
// Push fires the observer once.
|
||||
let cmd = vtable_command();
|
||||
assert_eq!(push_or_run(cmd, "alpha"), 0);
|
||||
push(noop_command(), "alpha").unwrap();
|
||||
assert_eq!(count().unwrap(), 2);
|
||||
assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
// Group begin/end fires the observer once at end.
|
||||
assert!(group_begin("grouped").is_ok());
|
||||
assert!(group_begin("again").is_err()); // State
|
||||
let c1 = vtable_command();
|
||||
let c2 = vtable_command();
|
||||
assert_eq!(push_or_run(c1, "c1"), 0);
|
||||
assert_eq!(push_or_run(c2, "c2"), 0);
|
||||
push(noop_command(), "c1").unwrap();
|
||||
push(noop_command(), "c2").unwrap();
|
||||
// Children joined the group: no observer fire yet.
|
||||
assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert_eq!(count().unwrap(), 2);
|
||||
@@ -457,8 +321,7 @@ mod tests {
|
||||
|
||||
// Abort fires nothing.
|
||||
assert!(group_begin("abort").is_ok());
|
||||
let c3 = vtable_command();
|
||||
assert_eq!(push_or_run(c3, "c3"), 0);
|
||||
push(noop_command(), "c3").unwrap();
|
||||
assert!(group_abort().is_ok());
|
||||
assert_eq!(count().unwrap(), 3);
|
||||
assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
@@ -470,9 +333,9 @@ mod tests {
|
||||
assert!(clear().is_ok());
|
||||
}
|
||||
|
||||
/// The safe value-typed surface (M14 R3): `push` redoes and records a
|
||||
/// closure command, `undo`/`redo` step the stack, and the observers
|
||||
/// fire on every recorded mutation.
|
||||
/// The value-typed surface: `push` redoes and records a closure
|
||||
/// command, `undo`/`redo` step the stack, and the observers fire on
|
||||
/// every recorded mutation.
|
||||
#[test]
|
||||
fn value_push_and_undo_redo() {
|
||||
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -511,9 +374,9 @@ mod tests {
|
||||
assert!(clear().is_ok());
|
||||
}
|
||||
|
||||
/// The safe row getters answer like the C-ABI twins: labels survive an
|
||||
/// undo (undone rows stay labeled) and `command_done` flips with the
|
||||
/// stack pointer.
|
||||
/// The row getters answer like the C-ABI twins: labels survive an undo
|
||||
/// (undone rows stay labeled) and `command_done` flips with the stack
|
||||
/// pointer.
|
||||
#[test]
|
||||
fn value_command_name_and_done() {
|
||||
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Refcounted-handle scaffolding. Same pattern as the oaknode/oakcodec
|
||||
//! crates (`src/node/rust/src/handle.rs`); intentionally duplicated rather
|
||||
//! than shared — each module DLL must run its own addref/release code
|
||||
//! (the function pointers in a handle always point into the DLL that
|
||||
//! created the object).
|
||||
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// ABI version stamped into every handle.
|
||||
pub const OAKUNDO_ABI_VERSION: u32 = 1;
|
||||
|
||||
/// Heap box behind a handle's `ctx`.
|
||||
pub struct RefBox<T: ?Sized> {
|
||||
/// Atomic reference count.
|
||||
pub refs: AtomicU32,
|
||||
/// Boxed value.
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
/// The shared ABI value-handle type (single-lib unification, see
|
||||
/// `docs/zh/plans/riir/single-lib.md`): one canonical
|
||||
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
|
||||
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
|
||||
/// source-compatible.
|
||||
pub use oakcore_rs::handle::CHandle;
|
||||
|
||||
/// Owned handle with count 1; empty on allocation failure.
|
||||
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
|
||||
let boxed = RefBox {
|
||||
refs: AtomicU32::new(1),
|
||||
value,
|
||||
};
|
||||
let ctx = Box::into_raw(Box::new(boxed)) as *mut std::ffi::c_void;
|
||||
CHandle {
|
||||
ctx,
|
||||
addref: Some(addref_owned::<T>),
|
||||
release: Some(release_owned::<T>),
|
||||
abi_version: OAKUNDO_ABI_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed handle for an object owned elsewhere (release frees only
|
||||
/// the box).
|
||||
///
|
||||
/// # Safety
|
||||
/// Caller guarantees `ptr` outlives every derived handle.
|
||||
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
|
||||
let boxed = RefBox {
|
||||
refs: AtomicU32::new(1),
|
||||
value: ptr,
|
||||
};
|
||||
let ctx = Box::into_raw(Box::new(boxed)) as *mut std::ffi::c_void;
|
||||
CHandle {
|
||||
ctx,
|
||||
addref: Some(addref_borrowed::<T>),
|
||||
release: Some(release_borrowed::<T>),
|
||||
abi_version: OAKUNDO_ABI_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed view into a handle; `None` for empty handles.
|
||||
///
|
||||
/// # Safety
|
||||
/// `T` must be the boxed type.
|
||||
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
|
||||
unsafe {
|
||||
if h.ctx.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(&(*(h.ctx as *mut RefBox<T>)).value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic-catching FFI wrapper for i32-returning exports.
|
||||
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
|
||||
match catch_unwind(AssertUnwindSafe(f)) {
|
||||
Ok(Ok(())) => crate::error::OAKUNDO_OK,
|
||||
Ok(Err(e)) => e.code(),
|
||||
Err(_) => crate::error::OAKUNDO_E_FAILED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic-catching FFI wrapper for handle-returning exports.
|
||||
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
|
||||
match catch_unwind(AssertUnwindSafe(f)) {
|
||||
Ok(Ok(h)) => h,
|
||||
_ => CHandle::null(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic-catching FFI wrapper for void exports.
|
||||
pub fn guard_void<F: FnOnce()>(f: F) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(f));
|
||||
}
|
||||
|
||||
/// Owned addref: bump the box's refcount.
|
||||
unsafe extern "C" fn addref_owned<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
let boxed = ctx as *mut RefBox<T>;
|
||||
(&(*boxed).refs).fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned release: drop the box when the refcount hits zero.
|
||||
unsafe extern "C" fn release_owned<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
let boxed = ctx as *mut RefBox<T>;
|
||||
if (&(*boxed).refs).fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
drop(Box::from_raw(boxed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed addref: bump the shell's refcount.
|
||||
unsafe extern "C" fn addref_borrowed<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
let boxed = ctx as *mut RefBox<*mut T>;
|
||||
(&(*boxed).refs).fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed release: free only the shell, never the pointee.
|
||||
unsafe extern "C" fn release_borrowed<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
let boxed = ctx as *mut RefBox<*mut T>;
|
||||
if (&(*boxed).refs).fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
drop(Box::from_raw(boxed));
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-10
@@ -16,22 +16,28 @@
|
||||
|
||||
//! # oakundo — the undo/redo history module (Rust)
|
||||
//!
|
||||
//! Reimplements the C++ oakundo module behind its frozen C ABI
|
||||
//! (`include/undo/*.h`). See README.md for the architectural mapping —
|
||||
//! most notably the **vtable-command pattern**: C++ subclassing of
|
||||
//! `olive::UndoCommand` becomes a callback table + `userdata` pointer.
|
||||
//! Reimplements the C++ oakundo module. See README.md for the
|
||||
//! architectural mapping — most notably the **trait-object command
|
||||
//! pattern**: C++ subclassing of `olive::UndoCommand` becomes a boxed
|
||||
//! [`undocommand::Command`] (closure-backed or a
|
||||
//! [`undocommand::MultiUndoCommand`] composite) behind a plain owned
|
||||
//! [`undocommand::UndoCommand`] value.
|
||||
//!
|
||||
//! ## FFI discipline
|
||||
//! ## Consumers
|
||||
//!
|
||||
//! Identical to the oaknode/oakcodec crates: every export goes through
|
||||
//! [`handle::guard*`], handles are opaque refcounted boxes, shared
|
||||
//! state behind `Mutex`.
|
||||
//! Every consumer is in-process Rust: the app's edit layer
|
||||
//! (`oakui::graphops` / `oakui::real`) drives the process-wide stack
|
||||
//! through [`global`], `oaktimeline`/`oaktask`/`oaknode`/`oakplugin`
|
||||
//! build commands as [`undocommand::UndoCommand`] values, and
|
||||
//! `oakstorage` subscribes to the command-success observers in
|
||||
//! [`global`]. The former frozen C ABI (`include/undo/*.h`), the
|
||||
//! refcounted [`CHandle`] layer and the callback-table vtable commands
|
||||
//! were deleted with the engine facade — the crate is pure owned-value
|
||||
//! Rust with no `unsafe`.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod error;
|
||||
pub mod global;
|
||||
pub mod handle;
|
||||
pub mod undocommand;
|
||||
pub mod undostack;
|
||||
|
||||
@@ -15,66 +15,42 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::UndoCommand` / `olive::MultiUndoCommand` — the undoable
|
||||
//! operation and its composite. Mirrors `src/undo/src/undocommand.h`
|
||||
//! and `include/undo/undocommand.h`.
|
||||
//! operation and its composite. Mirrors `src/undo/src/undocommand.h`.
|
||||
//!
|
||||
//! The C++ base is subclassed by other modules (`redo()`/`undo()`
|
||||
//! overrides). Rust has no inheritance, so the C ABI models the same
|
||||
//! polymorphism with a callback table + `userdata` pointer
|
||||
//! ([`OakUndoCommandVtable`]); this module's [`UndoCommand`] holds a
|
||||
//! [`CommandKind`] that is either that vtable-backed command or a
|
||||
//! [`MultiUndoCommand`] composite. This vtable-command pattern is the
|
||||
//! centerpiece of the oakundo architecture (see README.md).
|
||||
//!
|
||||
//! ## Command boxes
|
||||
//!
|
||||
//! Commands cross crate boundaries through a dedicated [`CommandBox`]
|
||||
//! (not the generic [`crate::handle::RefBox`]): it holds the raw
|
||||
//! `*mut UndoCommand`, an `owns` flag (mirroring the C++
|
||||
//! `OakUndoCommandBox`) and a refcount. An **owning** box owns and
|
||||
//! destroys its command at refcount zero; a **borrowed** box (a
|
||||
//! reference into a `MultiUndoCommand` or an `UndoStack`) owns only its
|
||||
//! shell. `take_command` moves the command value out of an owning box,
|
||||
//! turning it into a non-owning shell (the C++ `mark_container_owned`).
|
||||
//!
|
||||
//! The handle-level functions below (`command_init` ... `command_free`)
|
||||
//! were sunk from the former C ABI export layer: they keep the
|
||||
//! `CHandle`-based signatures so the facade bridges and integration
|
||||
//! tests can drive the command machinery directly. The `CHandle` layer
|
||||
//! itself is removed in a later milestone.
|
||||
|
||||
use std::ffi::{c_int, c_void};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
//! overrides); the Rust equivalent of that virtual dispatch is a
|
||||
//! trait-object command. [`UndoCommand`] boxes a [`Command`] (implemented
|
||||
//! by closure-backed commands and by the [`MultiUndoCommand`] composite)
|
||||
//! plus the command's state flags. This replaces the former
|
||||
//! `OakUndoCommandVtable` + `userdata` callback-table model, which only
|
||||
//! existed to cross the frozen C ABI (see README.md). With every consumer
|
||||
//! in-process there is no callback table, no `extern "C"` trampoline and
|
||||
//! no refcounted `CHandle` — commands are owned values.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::handle::{guard, guard_handle, guard_void, CHandle, OAKUNDO_ABI_VERSION};
|
||||
|
||||
/// `oakundo_command_vtable` — the caller-defined redo/undo/free
|
||||
/// callback table. Any entry may be `None`; a `None` redo/undo makes
|
||||
/// that direction a no-op, `None` free_fn skips userdata release.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct OakUndoCommandVtable {
|
||||
/// Execute the operation.
|
||||
pub redo: Option<unsafe extern "C" fn(*mut c_void)>,
|
||||
/// Reverse the operation.
|
||||
pub undo: Option<unsafe extern "C" fn(*mut c_void)>,
|
||||
/// Release `userdata` when the command is destroyed.
|
||||
pub free_fn: Option<unsafe extern "C" fn(*mut c_void)>,
|
||||
}
|
||||
/// A single undoable operation (the C++ virtual `redo()`/`undo()` pair).
|
||||
///
|
||||
/// Implementations must be `Send`: the owning [`UndoCommand`] lives in
|
||||
/// stacks shared behind a `Mutex`, so it may move across threads (exactly
|
||||
/// as the C++ commands did when the owning stack was shared).
|
||||
pub trait Command: Send {
|
||||
/// Apply the change.
|
||||
fn redo(&mut self);
|
||||
/// Revert the change.
|
||||
fn undo(&mut self);
|
||||
|
||||
/// What backs an [`UndoCommand`]: a caller-defined vtable command or a
|
||||
/// composite of children. The direct analog of C++ virtual dispatch.
|
||||
pub enum CommandKind {
|
||||
/// Caller-defined command: callback table plus opaque userdata.
|
||||
Vtable {
|
||||
/// The copied callback table.
|
||||
vtable: OakUndoCommandVtable,
|
||||
/// Opaque caller state; owned by the command.
|
||||
userdata: *mut c_void,
|
||||
},
|
||||
/// Composite command (`olive::MultiUndoCommand`).
|
||||
Multi(MultiUndoCommand),
|
||||
/// `Some` for composite commands; used by [`UndoCommand::multi_*`].
|
||||
/// Defaults to `None` (a plain command is never a composite).
|
||||
fn as_multi(&self) -> Option<&MultiUndoCommand> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `Some` for composite commands; used by [`UndoCommand::multi_*`].
|
||||
/// Defaults to `None` (a plain command is never a composite).
|
||||
fn as_multi_mut(&mut self) -> Option<&mut MultiUndoCommand> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `olive::UndoCommand` — an undoable operation.
|
||||
@@ -84,13 +60,13 @@ pub enum CommandKind {
|
||||
/// *callbacks* are not part of the C ABI and are intentionally omitted
|
||||
/// (see README.md decision 2); only the flag snapshot is retained.
|
||||
///
|
||||
/// `prepared` mirrors the C++ `prepared_` flag (the vtable has no
|
||||
/// `prepared` mirrors the C++ `prepared_` flag (the command trait has no
|
||||
/// `prepare()` callback, so it starts `true` and never un-prepares);
|
||||
/// `is_empty` marks the invariant bottom-of-stack command so the stack
|
||||
/// can keep it out of `can_undo`.
|
||||
pub struct UndoCommand {
|
||||
/// The operation (vtable or composite).
|
||||
kind: CommandKind,
|
||||
/// The operation (closure-backed or composite).
|
||||
inner: Box<dyn Command>,
|
||||
/// Whether the command has been executed (`done_`).
|
||||
done: bool,
|
||||
/// Project-dirty flag snapshot recorded at the last redo.
|
||||
@@ -105,17 +81,13 @@ pub struct UndoCommand {
|
||||
is_empty: bool,
|
||||
}
|
||||
|
||||
/// A command's userdata is caller-controlled; like the C++ object it
|
||||
/// may be moved across threads when the owning stack is shared behind a
|
||||
/// `Mutex`. Callbacks must be thread-safe with respect to the caller's
|
||||
/// own locking, exactly as in the C++ implementation.
|
||||
unsafe impl Send for UndoCommand {}
|
||||
|
||||
impl UndoCommand {
|
||||
/// New vtable-backed command; takes ownership of `userdata`.
|
||||
pub fn from_vtable(vtable: OakUndoCommandVtable, userdata: *mut c_void) -> Self {
|
||||
/// Wrap any [`Command`] as an undoable command value. This is the
|
||||
/// construction path for whole-struct commands (the app's timeline
|
||||
/// and task commands); one-off edits use [`UndoCommand::from_closures`].
|
||||
pub fn new(command: impl Command + 'static) -> Self {
|
||||
UndoCommand {
|
||||
kind: CommandKind::Vtable { vtable, userdata },
|
||||
inner: Box::new(command),
|
||||
done: false,
|
||||
modified: false,
|
||||
prepared: true,
|
||||
@@ -123,89 +95,57 @@ impl UndoCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// New empty composite command (`olive::MultiUndoCommand`).
|
||||
pub fn multi() -> Self {
|
||||
UndoCommand {
|
||||
kind: CommandKind::Multi(MultiUndoCommand::new()),
|
||||
done: false,
|
||||
modified: false,
|
||||
prepared: true,
|
||||
is_empty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// New closure-backed command (M14 R3): `redo`/`undo` are plain Rust
|
||||
/// closures, boxed behind the vtable machinery. This is the safe way
|
||||
/// for direct-rlib consumers (the app) to build composite edit
|
||||
/// commands without writing their own `extern "C"` trampolines.
|
||||
/// New closure-backed command: `redo` runs on redo, `undo` on undo.
|
||||
/// The safe way for direct-rlib consumers (the app) to build edit
|
||||
/// commands without writing any callback machinery.
|
||||
pub fn from_closures(
|
||||
redo: impl FnMut() + Send + 'static,
|
||||
undo: impl FnMut() + Send + 'static,
|
||||
) -> Self {
|
||||
let state = Box::into_raw(Box::new(ClosureCommand {
|
||||
UndoCommand::new(ClosureCommand {
|
||||
redo: Box::new(redo),
|
||||
undo: Box::new(undo),
|
||||
}));
|
||||
UndoCommand::from_vtable(
|
||||
OakUndoCommandVtable {
|
||||
redo: Some(closure_redo),
|
||||
undo: Some(closure_undo),
|
||||
free_fn: Some(closure_free),
|
||||
},
|
||||
state as *mut c_void,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The invariant bottom-of-stack command ("New/Open Project"); all
|
||||
/// callbacks are no-ops and it is never undoable.
|
||||
/// New empty composite command (`olive::MultiUndoCommand`).
|
||||
pub fn multi() -> Self {
|
||||
UndoCommand::new(MultiUndoCommand::new())
|
||||
}
|
||||
|
||||
/// The invariant bottom-of-stack command ("New/Open Project"); a
|
||||
/// no-op in both directions and never undoable.
|
||||
pub(crate) fn empty() -> Self {
|
||||
UndoCommand {
|
||||
kind: CommandKind::Vtable {
|
||||
vtable: OakUndoCommandVtable {
|
||||
redo: None,
|
||||
undo: None,
|
||||
free_fn: None,
|
||||
},
|
||||
userdata: std::ptr::null_mut(),
|
||||
},
|
||||
done: false,
|
||||
modified: false,
|
||||
prepared: true,
|
||||
is_empty: true,
|
||||
}
|
||||
let mut command = UndoCommand::from_closures(|| {}, || {});
|
||||
command.is_empty = true;
|
||||
command
|
||||
}
|
||||
|
||||
/// Add `child` to a composite command (takes one reference).
|
||||
pub fn multi_add_child(&mut self, child: UndoCommand) {
|
||||
match &mut self.kind {
|
||||
CommandKind::Multi(m) => m.add_child(child),
|
||||
CommandKind::Vtable { .. } => {
|
||||
panic!("multi_add_child on a non-multi command")
|
||||
}
|
||||
match self.inner.as_multi_mut() {
|
||||
Some(m) => m.add_child(child),
|
||||
None => panic!("multi_add_child on a non-multi command"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of children of a composite command.
|
||||
/// Number of children of a composite command (0 for plain commands).
|
||||
pub fn multi_child_count(&self) -> usize {
|
||||
match &self.kind {
|
||||
CommandKind::Multi(m) => m.child_count(),
|
||||
CommandKind::Vtable { .. } => 0,
|
||||
}
|
||||
self.inner.as_multi().map_or(0, |m| m.child_count())
|
||||
}
|
||||
|
||||
/// Reference to the child at `index` of a composite command.
|
||||
pub fn multi_child(&self, index: usize) -> Result<&UndoCommand> {
|
||||
match &self.kind {
|
||||
CommandKind::Multi(m) => m.child(index),
|
||||
CommandKind::Vtable { .. } => Err(Error::Invalid),
|
||||
}
|
||||
self.inner
|
||||
.as_multi()
|
||||
.map_or(Err(Error::Invalid), |m| m.child(index))
|
||||
}
|
||||
|
||||
/// Mutable reference to the child at `index` of a composite command.
|
||||
pub fn multi_child_mut(&mut self, index: usize) -> Result<&mut UndoCommand> {
|
||||
match &mut self.kind {
|
||||
CommandKind::Multi(m) => m.child_mut(index),
|
||||
CommandKind::Vtable { .. } => Err(Error::Invalid),
|
||||
match self.inner.as_multi_mut() {
|
||||
Some(m) => m.child_mut(index),
|
||||
None => Err(Error::Invalid),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +179,8 @@ impl UndoCommand {
|
||||
self.undo_now();
|
||||
}
|
||||
|
||||
/// `has_prepared`: whether `prepare()` has run (vtable commands have
|
||||
/// no prepare; always true).
|
||||
/// `has_prepared`: whether `prepare()` has run (commands have no
|
||||
/// prepare; always true).
|
||||
pub fn has_prepared(&self) -> bool {
|
||||
self.prepared
|
||||
}
|
||||
@@ -267,49 +207,17 @@ impl UndoCommand {
|
||||
|
||||
/// Whether this is a composite (`MultiUndoCommand`) command.
|
||||
pub(crate) fn is_multi(&self) -> bool {
|
||||
matches!(self.kind, CommandKind::Multi(_))
|
||||
self.inner.as_multi().is_some()
|
||||
}
|
||||
|
||||
/// Run the operation (virtual dispatch).
|
||||
fn redo(&mut self) {
|
||||
match &mut self.kind {
|
||||
CommandKind::Vtable { vtable, userdata } => {
|
||||
if let Some(f) = vtable.redo {
|
||||
// SAFETY: `userdata` is caller-supplied and outlives
|
||||
// the command (owned by it). The callback is the one
|
||||
// registered by the caller.
|
||||
unsafe { f(*userdata) }
|
||||
}
|
||||
}
|
||||
CommandKind::Multi(m) => m.redo(),
|
||||
}
|
||||
self.inner.redo();
|
||||
}
|
||||
|
||||
/// Reverse the operation (virtual dispatch).
|
||||
fn undo(&mut self) {
|
||||
match &mut self.kind {
|
||||
CommandKind::Vtable { vtable, userdata } => {
|
||||
if let Some(f) = vtable.undo {
|
||||
// SAFETY: as in `redo`.
|
||||
unsafe { f(*userdata) }
|
||||
}
|
||||
}
|
||||
CommandKind::Multi(m) => m.undo(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UndoCommand {
|
||||
/// Invokes the vtable `free_fn` on `userdata` (composites free
|
||||
/// children transitively).
|
||||
fn drop(&mut self) {
|
||||
if let CommandKind::Vtable { vtable, userdata } = &self.kind {
|
||||
if let Some(f) = vtable.free_fn {
|
||||
// SAFETY: `userdata` is owned by the command; this is the
|
||||
// final release.
|
||||
unsafe { f(*userdata) }
|
||||
}
|
||||
}
|
||||
self.inner.undo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,43 +229,14 @@ struct ClosureCommand {
|
||||
undo: Box<dyn FnMut() + Send>,
|
||||
}
|
||||
|
||||
/// `redo` trampoline for closure commands.
|
||||
///
|
||||
/// # Safety
|
||||
/// `userdata` must be the `Box<ClosureCommand>` produced by
|
||||
/// [`UndoCommand::from_closures`]; the owning command keeps it alive.
|
||||
unsafe extern "C" fn closure_redo(userdata: *mut c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
impl Command for ClosureCommand {
|
||||
fn redo(&mut self) {
|
||||
(self.redo)();
|
||||
}
|
||||
// SAFETY: per the from_closures contract; the command owns the box.
|
||||
let state = unsafe { &mut *(userdata as *mut ClosureCommand) };
|
||||
(state.redo)();
|
||||
}
|
||||
|
||||
/// `undo` trampoline for closure commands.
|
||||
///
|
||||
/// # Safety
|
||||
/// As [`closure_redo`].
|
||||
unsafe extern "C" fn closure_undo(userdata: *mut c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
fn undo(&mut self) {
|
||||
(self.undo)();
|
||||
}
|
||||
// SAFETY: per the from_closures contract; the command owns the box.
|
||||
let state = unsafe { &mut *(userdata as *mut ClosureCommand) };
|
||||
(state.undo)();
|
||||
}
|
||||
|
||||
/// `free` trampoline for closure commands: drops the boxed closures.
|
||||
///
|
||||
/// # Safety
|
||||
/// As [`closure_redo`]; called at most once (the command's final release).
|
||||
unsafe extern "C" fn closure_free(userdata: *mut c_void) {
|
||||
if userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: per the from_closures contract; this is the final release.
|
||||
unsafe { drop(Box::from_raw(userdata as *mut ClosureCommand)) };
|
||||
}
|
||||
|
||||
/// `olive::MultiUndoCommand` — a composite of child commands.
|
||||
@@ -369,9 +248,6 @@ pub struct MultiUndoCommand {
|
||||
children: Vec<UndoCommand>,
|
||||
}
|
||||
|
||||
/// See `UndoCommand::Send`.
|
||||
unsafe impl Send for MultiUndoCommand {}
|
||||
|
||||
impl MultiUndoCommand {
|
||||
/// New empty composite.
|
||||
pub fn new() -> Self {
|
||||
@@ -421,250 +297,20 @@ impl Default for MultiUndoCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// Heap box behind a command handle's `ctx`. Mirrors the C++
|
||||
/// `OakUndoCommandBox` (`{command, owns, refs}`).
|
||||
pub(crate) struct CommandBox {
|
||||
/// Raw pointer to the command value.
|
||||
command: *mut UndoCommand,
|
||||
/// Whether this box owns (and must destroy) `command`.
|
||||
owns: bool,
|
||||
/// Atomic reference count.
|
||||
refs: AtomicU32,
|
||||
}
|
||||
impl Command for MultiUndoCommand {
|
||||
fn redo(&mut self) {
|
||||
MultiUndoCommand::redo(self);
|
||||
}
|
||||
|
||||
/// Owned handle for a fresh command value (refcount 1, owns `cmd`).
|
||||
///
|
||||
/// # Safety
|
||||
/// The returned handle owns `cmd`; release it with `command_release`.
|
||||
pub unsafe fn command_from_owned(cmd: UndoCommand) -> CHandle {
|
||||
let boxed = Box::into_raw(Box::new(CommandBox {
|
||||
command: Box::into_raw(Box::new(cmd)),
|
||||
owns: true,
|
||||
refs: AtomicU32::new(1),
|
||||
})) as *mut c_void;
|
||||
CHandle {
|
||||
ctx: boxed,
|
||||
addref: Some(command_addref),
|
||||
release: Some(command_release),
|
||||
abi_version: OAKUNDO_ABI_VERSION,
|
||||
fn undo(&mut self) {
|
||||
MultiUndoCommand::undo(self);
|
||||
}
|
||||
|
||||
fn as_multi(&self) -> Option<&MultiUndoCommand> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn as_multi_mut(&mut self) -> Option<&mut MultiUndoCommand> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed handle for a command owned elsewhere (release frees only
|
||||
/// the shell).
|
||||
///
|
||||
/// # Safety
|
||||
/// `cmd` must outlive the returned handle.
|
||||
pub unsafe fn command_from_borrowed(cmd: *mut UndoCommand) -> CHandle {
|
||||
let boxed = Box::into_raw(Box::new(CommandBox {
|
||||
command: cmd,
|
||||
owns: false,
|
||||
refs: AtomicU32::new(1),
|
||||
})) as *mut c_void;
|
||||
CHandle {
|
||||
ctx: boxed,
|
||||
addref: Some(command_addref),
|
||||
release: Some(command_release),
|
||||
abi_version: OAKUNDO_ABI_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of the command behind `ctx`; `None` for a null handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ctx` must come from a valid, live `CommandBox`.
|
||||
pub unsafe fn command_to_ref(ctx: *mut c_void) -> Option<&'static UndoCommand> {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return None;
|
||||
}
|
||||
let boxed = ctx as *mut CommandBox;
|
||||
if (*boxed).command.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(&*(*boxed).command)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable view of the command behind `ctx`; `None` for a null handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ctx` must come from a valid, live `CommandBox`.
|
||||
pub unsafe fn command_to_mut(ctx: *mut c_void) -> Option<&'static mut UndoCommand> {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return None;
|
||||
}
|
||||
let boxed = ctx as *mut CommandBox;
|
||||
if (*boxed).command.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(&mut *(*boxed).command)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the command value out of an owning box, turning it into a
|
||||
/// non-owning shell (the C++ `mark_container_owned` transfer).
|
||||
///
|
||||
/// # Safety
|
||||
/// `ctx` must come from a valid, live `CommandBox`.
|
||||
pub unsafe fn command_take(ctx: *mut c_void) -> Result<UndoCommand> {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let boxed = ctx as *mut CommandBox;
|
||||
if !(*boxed).owns || (*boxed).command.is_null() {
|
||||
return Err(Error::State);
|
||||
}
|
||||
let value = Box::from_raw((*boxed).command);
|
||||
(*boxed).command = std::ptr::null_mut();
|
||||
(*boxed).owns = false;
|
||||
Ok(*value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment a command box's refcount (the `addref` function pointer).
|
||||
///
|
||||
/// # Safety
|
||||
/// `ctx` must come from a valid `CommandBox`.
|
||||
pub(crate) unsafe extern "C" fn command_addref(ctx: *mut c_void) {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
let boxed = ctx as *mut CommandBox;
|
||||
(&(*boxed).refs).fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement a command box's refcount; destroys at zero. Owned boxes
|
||||
/// free their command first, then the shell; borrowed boxes free only
|
||||
/// the shell.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ctx` must come from a valid `CommandBox`.
|
||||
pub(crate) unsafe extern "C" fn command_release(ctx: *mut c_void) {
|
||||
unsafe {
|
||||
if ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
let boxed = ctx as *mut CommandBox;
|
||||
if (&(*boxed).refs).fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
if (*boxed).owns && !(*boxed).command.is_null() {
|
||||
drop(Box::from_raw((*boxed).command));
|
||||
}
|
||||
drop(Box::from_raw(boxed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handle-level command API (sunk from the former C ABI export layer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a vtable-backed command handle (refcount 1); a `NULL` vtable
|
||||
/// yields an empty handle (`oakundo_command_init`).
|
||||
pub unsafe fn command_init(vtable: *const OakUndoCommandVtable, userdata: *mut c_void) -> CHandle {
|
||||
guard_handle(|| unsafe {
|
||||
if vtable.is_null() {
|
||||
return Ok(CHandle::null());
|
||||
}
|
||||
let table = *vtable;
|
||||
Ok(command_from_owned(UndoCommand::from_vtable(table, userdata)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an empty multi command handle (refcount 1)
|
||||
/// (`oakundo_command_init_multi`).
|
||||
pub fn command_init_multi() -> CHandle {
|
||||
guard_handle(|| unsafe { Ok(command_from_owned(UndoCommand::multi())) })
|
||||
}
|
||||
|
||||
/// Add `child` to a multi command handle (the stack/multi takes one
|
||||
/// child reference); `E_INVALID` for an empty handle or a non-multi
|
||||
/// parent (`oakundo_command_multi_add_child`).
|
||||
pub fn command_multi_add_child(multi: CHandle, child: CHandle) -> c_int {
|
||||
guard(|| unsafe {
|
||||
let parent = command_to_mut(multi.ctx).ok_or(Error::Invalid)?;
|
||||
if !parent.is_multi() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let child_cmd = command_take(child.ctx)?;
|
||||
parent.multi_add_child(child_cmd);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of children of a multi command handle; `E_INVALID` for an
|
||||
/// empty handle or a non-multi parent (`oakundo_command_multi_child_count`).
|
||||
pub unsafe fn command_multi_child_count(multi: CHandle, out_count: *mut c_int) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if out_count.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let parent = command_to_ref(multi.ctx).ok_or(Error::Invalid)?;
|
||||
if !parent.is_multi() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
*out_count = parent.multi_child_count() as c_int;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a borrowed handle to the child at `index` of a multi command
|
||||
/// (the returned handle carries its own shell ref); `E_NOT_FOUND` for
|
||||
/// an out-of-range index (`oakundo_command_multi_child`).
|
||||
pub unsafe fn command_multi_child(multi: CHandle, index: c_int, out_child: *mut CHandle) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if out_child.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let parent = command_to_ref(multi.ctx).ok_or(Error::Invalid)?;
|
||||
if !parent.is_multi() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
if index < 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let child = parent.multi_child(index as usize)?;
|
||||
let ptr = child as *const UndoCommand as *mut UndoCommand;
|
||||
*out_child = command_from_borrowed(ptr);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a command handle's redo (no-op when already done)
|
||||
/// (`oakundo_command_redo_now`).
|
||||
pub fn command_redo_now(command: CHandle) -> c_int {
|
||||
guard(|| unsafe {
|
||||
let c = command_to_mut(command.ctx).ok_or(Error::Invalid)?;
|
||||
c.redo_now();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a command handle's undo (no-op when not done)
|
||||
/// (`oakundo_command_undo_now`).
|
||||
pub fn command_undo_now(command: CHandle) -> c_int {
|
||||
guard(|| unsafe {
|
||||
let c = command_to_mut(command.ctx).ok_or(Error::Invalid)?;
|
||||
c.undo_now();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Release a command handle in place: run the release callback, then
|
||||
/// clear `ctx`. `NULL` / empty handles are no-ops
|
||||
/// (`oakundo_command_free`).
|
||||
pub unsafe fn command_free(command: *mut CHandle) {
|
||||
guard_void(|| unsafe {
|
||||
if command.is_null() || (*command).ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
if let Some(release) = (*command).release {
|
||||
release((*command).ctx);
|
||||
}
|
||||
(*command).ctx = std::ptr::null_mut();
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::UndoStack` — the undo/redo history stack. Mirrors
|
||||
//! `src/undo/src/undostack.h` and `include/undo/undostack.h`.
|
||||
//! `src/undo/src/undostack.h`.
|
||||
//!
|
||||
//! Two deques: `commands` (done commands, oldest at the front) and
|
||||
//! `undone` (undone commands, most-recently-undone at the front). The
|
||||
//! fresh stack holds a single "New/Open Project" empty command so that
|
||||
//! `can_undo` is false at the bottom (see `undostack.cpp::clear`).
|
||||
//!
|
||||
//! The former handle-level exports (`undostack_init` … `undostack_free`)
|
||||
//! are gone with the C ABI: the stack is a plain owned value, shared
|
||||
//! process-wide through the [`crate::global`] module.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::{c_char, c_int, CStr};
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::error::{Error, OAKUNDO_E_FAILED, Result};
|
||||
use crate::handle::{get, guard, guard_handle, guard_void, make_owned, CHandle};
|
||||
use crate::undocommand::{command_take, UndoCommand};
|
||||
use crate::error::Result;
|
||||
use crate::undocommand::UndoCommand;
|
||||
|
||||
/// Maximum number of retained history rows (`k_max_undo_commands`).
|
||||
pub const K_MAX_UNDO_COMMANDS: usize = 200;
|
||||
@@ -67,9 +67,6 @@ pub struct UndoStack {
|
||||
undone: VecDeque<CommandEntry>,
|
||||
}
|
||||
|
||||
/// See `UndoCommand::Send`.
|
||||
unsafe impl Send for UndoStack {}
|
||||
|
||||
impl UndoStack {
|
||||
/// New stack; contains a single empty "New/Open Project" command.
|
||||
pub fn new() -> Self {
|
||||
@@ -198,7 +195,7 @@ impl UndoStack {
|
||||
/// Whether the row at `row` is currently done.
|
||||
pub fn command_is_done(&self, row: i64) -> Result<bool> {
|
||||
if row < 0 || row >= self.command_count() {
|
||||
return Err(Error::NotFound);
|
||||
return Err(crate::error::Error::NotFound);
|
||||
}
|
||||
Ok(row < self.commands.len() as i64)
|
||||
}
|
||||
@@ -206,7 +203,7 @@ impl UndoStack {
|
||||
/// Label of the history row at `row`.
|
||||
pub fn command_name(&self, row: i64) -> Result<&str> {
|
||||
if row < 0 || row >= self.command_count() {
|
||||
return Err(Error::NotFound);
|
||||
return Err(crate::error::Error::NotFound);
|
||||
}
|
||||
let row = row as usize;
|
||||
if row < self.commands.len() {
|
||||
@@ -222,204 +219,3 @@ impl Default for UndoStack {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handle-level stack API (sunk from the former C ABI export layer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a NUL-terminated C string; `NULL` yields an empty string
|
||||
/// (mirrors the C++ `name ? name : ""`).
|
||||
fn read_name(name: *const c_char) -> String {
|
||||
if name.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
// SAFETY: `name` is a valid NUL-terminated string supplied by the
|
||||
// caller, or NULL (already handled).
|
||||
unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock the stack behind `stack` and run `f` on it. `E_INVALID` for an
|
||||
/// empty handle. A poisoned mutex is recovered (its inner value is still
|
||||
/// valid).
|
||||
pub fn with_stack<R>(stack: &CHandle, f: impl FnOnce(&mut UndoStack) -> Result<R>) -> Result<R> {
|
||||
// SAFETY: the stack handle always boxes a `Mutex<UndoStack>` (created
|
||||
// by `undostack_init`).
|
||||
let m = unsafe { get::<Mutex<UndoStack>>(stack) }.ok_or(Error::Invalid)?;
|
||||
let mut guard = m.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(&mut guard)
|
||||
}
|
||||
|
||||
/// Create a fresh stack handle (refcount 1) (`oakundo_undostack_init`).
|
||||
pub fn undostack_init() -> CHandle {
|
||||
guard_handle(|| Ok(make_owned(Mutex::new(UndoStack::new()))))
|
||||
}
|
||||
|
||||
/// Release a stack handle in place; `NULL` / empty handles are no-ops
|
||||
/// (`oakundo_undostack_free`).
|
||||
pub fn undostack_free(stack: *mut CHandle) {
|
||||
guard_void(|| unsafe {
|
||||
if stack.is_null() || (*stack).ctx.is_null() {
|
||||
return;
|
||||
}
|
||||
if let Some(release) = (*stack).release {
|
||||
release((*stack).ctx);
|
||||
}
|
||||
(*stack).ctx = std::ptr::null_mut();
|
||||
})
|
||||
}
|
||||
|
||||
/// Push a command handle onto the stack (redo then record; the stack
|
||||
/// takes ownership of the command value, leaving a non-owning shell)
|
||||
/// (`oakundo_undostack_push`).
|
||||
pub fn undostack_push(stack: CHandle, command: CHandle, name: *const c_char) -> c_int {
|
||||
guard(|| {
|
||||
with_stack(&stack, |s| unsafe {
|
||||
let cmd = command_take(command.ctx)?;
|
||||
let name = read_name(name);
|
||||
s.push(cmd, &name);
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Push an already-executed command handle (redo skipped)
|
||||
/// (`oakundo_undostack_push_pre_executed`).
|
||||
pub fn undostack_push_pre_executed(stack: CHandle, command: CHandle, name: *const c_char) -> c_int {
|
||||
guard(|| {
|
||||
with_stack(&stack, |s| unsafe {
|
||||
let cmd = command_take(command.ctx)?;
|
||||
let name = read_name(name);
|
||||
s.push_pre_executed(cmd, &name);
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Undo one command on the stack (`oakundo_undostack_undo`).
|
||||
pub fn undostack_undo(stack: CHandle) -> c_int {
|
||||
guard(|| with_stack(&stack, |s| s.undo()))
|
||||
}
|
||||
|
||||
/// Redo one command on the stack (`oakundo_undostack_redo`).
|
||||
pub fn undostack_redo(stack: CHandle) -> c_int {
|
||||
guard(|| with_stack(&stack, |s| s.redo()))
|
||||
}
|
||||
|
||||
/// Jump to a done-command index (clamped to 0; `index` is i64)
|
||||
/// (`oakundo_undostack_jump`).
|
||||
pub fn undostack_jump(stack: CHandle, index: i64) -> c_int {
|
||||
guard(|| {
|
||||
with_stack(&stack, |s| {
|
||||
s.jump(index);
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear the stack back to the empty bottom command
|
||||
/// (`oakundo_undostack_clear`).
|
||||
pub fn undostack_clear(stack: CHandle) -> c_int {
|
||||
guard(|| {
|
||||
with_stack(&stack, |s| {
|
||||
s.clear();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Write whether an undo is possible (`oakundo_undostack_can_undo`).
|
||||
pub fn undostack_can_undo(stack: CHandle, out_value: *mut c_int) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if out_value.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
with_stack(&stack, |s| {
|
||||
*out_value = if s.can_undo() { 1 } else { 0 };
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Write whether a redo is possible (`oakundo_undostack_can_redo`).
|
||||
pub fn undostack_can_redo(stack: CHandle, out_value: *mut c_int) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if out_value.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
with_stack(&stack, |s| {
|
||||
*out_value = if s.can_redo() { 1 } else { 0 };
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the total history row count (`oakundo_undostack_count`).
|
||||
pub fn undostack_count(stack: CHandle, out_count: *mut i64) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if out_count.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
with_stack(&stack, |s| {
|
||||
*out_count = s.command_count();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the done-command count (`oakundo_undostack_index`).
|
||||
pub fn undostack_index(stack: CHandle, out_index: *mut i64) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if out_index.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
with_stack(&stack, |s| {
|
||||
*out_index = s.done_count();
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Two-stage label getter for the row at `row`: returns the required
|
||||
/// size (including NUL), or an error code; copies (truncating) when a
|
||||
/// buffer is supplied (`oakundo_undostack_command_text`).
|
||||
pub fn undostack_command_text(stack: CHandle, row: i64, buf: *mut c_char, buf_size: c_int) -> c_int {
|
||||
let result = catch_unwind(AssertUnwindSafe(|| -> Result<i32> {
|
||||
with_stack(&stack, |s| {
|
||||
if row < 0 || row >= s.command_count() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let name = s.command_name(row)?;
|
||||
let required = (name.len() + 1) as i32;
|
||||
if !buf.is_null() && buf_size > 0 {
|
||||
let copy_len = name.len().min((buf_size as usize).saturating_sub(1));
|
||||
let bytes = name.as_bytes();
|
||||
// SAFETY: `buf` points to `buf_size` writable bytes and we
|
||||
// write at most `copy_len` (+ one NUL) of them.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy_len);
|
||||
*buf.add(copy_len) = 0;
|
||||
}
|
||||
}
|
||||
Ok(required)
|
||||
})
|
||||
}));
|
||||
match result {
|
||||
Ok(Ok(required)) => required,
|
||||
Ok(Err(e)) => e.code(),
|
||||
Err(_) => OAKUNDO_E_FAILED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write whether the row at `row` is done (`oakundo_undostack_command_is_done`).
|
||||
pub fn undostack_command_is_done(stack: CHandle, row: i64, out_value: *mut c_int) -> c_int {
|
||||
guard(|| unsafe {
|
||||
if out_value.is_null() {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
with_stack(&stack, |s| {
|
||||
*out_value = if s.command_is_done(row)? { 1 } else { 0 };
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,25 +14,15 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Edge-path coverage: error-code mapping, borrowed handles, refcount
|
||||
//! symmetry, shell handles after `command_take`, `Default` impls, and the
|
||||
//! `push_pre_executed` redo-tail/cap paths. Everything here goes through
|
||||
//! the crate's public Rust API (the handle-level functions sunk from the
|
||||
//! former C ABI layer).
|
||||
//! Edge-path coverage: error-code mapping, `Default` impls, the
|
||||
//! prepared flag, and the `push_pre_executed` redo-tail/cap paths.
|
||||
//! Everything here goes through the crate's public value-typed API
|
||||
//! (the former handle-level/refcount tests were removed together with
|
||||
//! the `CHandle` layer they exercised).
|
||||
|
||||
use oakundo::error::{
|
||||
Error, OAKUNDO_E_FAILED, OAKUNDO_E_INVALID, OAKUNDO_E_NOMEM, OAKUNDO_E_NOT_FOUND,
|
||||
OAKUNDO_E_STATE,
|
||||
};
|
||||
use oakundo::handle::{make_borrowed, make_owned};
|
||||
use oakundo::undocommand::{
|
||||
command_free, command_init, command_init_multi, command_multi_child_count, command_redo_now,
|
||||
MultiUndoCommand, OakUndoCommandVtable, UndoCommand,
|
||||
};
|
||||
use oakundo::undostack::{
|
||||
undostack_can_redo, undostack_free, undostack_index, undostack_init, undostack_push,
|
||||
undostack_push_pre_executed, undostack_undo, EmptyCommand, UndoStack, K_MAX_UNDO_COMMANDS,
|
||||
};
|
||||
use oakundo::error::{Error, OAKUNDO_E_FAILED, OAKUNDO_E_INVALID, OAKUNDO_E_NOMEM, OAKUNDO_E_NOT_FOUND, OAKUNDO_E_STATE};
|
||||
use oakundo::undocommand::{MultiUndoCommand, UndoCommand};
|
||||
use oakundo::undostack::{EmptyCommand, UndoStack, K_MAX_UNDO_COMMANDS};
|
||||
|
||||
/// Every `Error` variant maps to its documented public code.
|
||||
#[test]
|
||||
@@ -44,40 +34,6 @@ fn error_code_mapping_is_complete() {
|
||||
assert_eq!(Error::NoMem.code(), OAKUNDO_E_NOMEM);
|
||||
}
|
||||
|
||||
/// Borrowed handles: addref/release only touch the shell, never the
|
||||
/// pointee; NULL ctx is a no-op for both.
|
||||
#[test]
|
||||
fn borrowed_handle_refcounting() {
|
||||
let mut value: i32 = 7;
|
||||
let h = unsafe { make_borrowed(&mut value as *mut i32) };
|
||||
assert!(!h.is_null());
|
||||
|
||||
// NULL ctx is a no-op.
|
||||
unsafe { h.addref.unwrap()(std::ptr::null_mut()) };
|
||||
unsafe { h.release.unwrap()(std::ptr::null_mut()) };
|
||||
|
||||
// addref then two releases: the pointee survives (still readable).
|
||||
unsafe { h.addref.unwrap()(h.ctx) };
|
||||
unsafe { h.release.unwrap()(h.ctx) };
|
||||
assert_eq!(value, 7);
|
||||
unsafe { h.release.unwrap()(h.ctx) };
|
||||
assert_eq!(value, 7);
|
||||
}
|
||||
|
||||
/// Owned handles: addref requires a matching extra release; releasing to
|
||||
/// zero destroys the box exactly once.
|
||||
#[test]
|
||||
fn owned_handle_refcounting() {
|
||||
let h = make_owned(String::from("owned"));
|
||||
assert!(!h.is_null());
|
||||
unsafe { h.addref.unwrap()(h.ctx) };
|
||||
unsafe { h.release.unwrap()(h.ctx) };
|
||||
// Still alive (one reference left) and readable.
|
||||
let view = unsafe { oakundo::handle::get::<String>(&h) };
|
||||
assert_eq!(view.map(String::as_str), Some("owned"));
|
||||
unsafe { h.release.unwrap()(h.ctx) };
|
||||
}
|
||||
|
||||
/// `Default` impls mirror `new()`.
|
||||
#[test]
|
||||
fn default_impls_match_new() {
|
||||
@@ -96,130 +52,51 @@ fn default_impls_match_new() {
|
||||
/// `set_prepared` is idempotent and `has_prepared` reflects it.
|
||||
#[test]
|
||||
fn prepared_flag_roundtrip() {
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: None,
|
||||
undo: None,
|
||||
free_fn: None,
|
||||
};
|
||||
let mut cmd = UndoCommand::from_vtable(vtable, std::ptr::null_mut());
|
||||
let mut cmd = UndoCommand::from_closures(|| {}, || {});
|
||||
assert!(cmd.has_prepared());
|
||||
cmd.set_prepared();
|
||||
assert!(cmd.has_prepared());
|
||||
}
|
||||
|
||||
/// A command handle whose value was taken by a stack push becomes a
|
||||
/// non-owning shell: redo/undo on it are `E_INVALID`, and pushing it a
|
||||
/// second time is `E_STATE`.
|
||||
#[test]
|
||||
fn taken_command_shell_is_inert() {
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: None,
|
||||
undo: None,
|
||||
free_fn: None,
|
||||
};
|
||||
let mut stack = undostack_init();
|
||||
let cmd = command_init(&vtable, std::ptr::null_mut());
|
||||
assert!(!cmd.ctx.is_null());
|
||||
|
||||
let name = c"once";
|
||||
assert_eq!(undostack_push(stack, cmd, name.as_ptr()), 0);
|
||||
|
||||
// The shell no longer holds a command value.
|
||||
assert_eq!(command_redo_now(cmd), OAKUNDO_E_INVALID);
|
||||
// Taking the same box twice is a state error.
|
||||
assert_eq!(undostack_push(stack, cmd, name.as_ptr()), OAKUNDO_E_STATE);
|
||||
|
||||
let mut release = cmd;
|
||||
command_free(&mut release);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
/// `push_pre_executed` also drops the redoable tail and evicts the oldest
|
||||
/// row past the cap (mirrors `push`).
|
||||
/// `push_pre_executed` drops the redoable tail and evicts the oldest row
|
||||
/// past the cap (mirrors `push`).
|
||||
#[test]
|
||||
fn push_pre_executed_clears_redo_tail_and_caps() {
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: None,
|
||||
undo: None,
|
||||
free_fn: None,
|
||||
};
|
||||
let mut stack = undostack_init();
|
||||
let name = c"row";
|
||||
let mut stack = UndoStack::new();
|
||||
let name = "row";
|
||||
|
||||
// Push two, undo one, then push_pre_executed: redo tail is dropped.
|
||||
for _ in 0..2 {
|
||||
let cmd = command_init(&vtable, std::ptr::null_mut());
|
||||
assert_eq!(undostack_push(stack, cmd, name.as_ptr()), 0);
|
||||
let mut shell = cmd;
|
||||
command_free(&mut shell);
|
||||
stack.push(UndoCommand::from_closures(|| {}, || {}), name);
|
||||
}
|
||||
assert_eq!(undostack_undo(stack), 0);
|
||||
let cmd = command_init(&vtable, std::ptr::null_mut());
|
||||
assert_eq!(undostack_push_pre_executed(stack, cmd, name.as_ptr()), 0);
|
||||
let mut can_redo: i32 = 1;
|
||||
assert_eq!(undostack_can_redo(stack, &mut can_redo), 0);
|
||||
assert_eq!(can_redo, 0, "push_pre_executed drops the redoable tail");
|
||||
stack.undo().unwrap();
|
||||
stack.push_pre_executed(UndoCommand::from_closures(|| {}, || {}), name);
|
||||
assert!(!stack.can_redo(), "push_pre_executed drops the redoable tail");
|
||||
|
||||
// Fill past the cap with pre-executed commands: the oldest rows are
|
||||
// evicted and the count stays at K_MAX_UNDO_COMMANDS.
|
||||
for _ in 0..(K_MAX_UNDO_COMMANDS + 10) {
|
||||
let cmd = command_init(&vtable, std::ptr::null_mut());
|
||||
assert_eq!(undostack_push_pre_executed(stack, cmd, name.as_ptr()), 0);
|
||||
stack.push_pre_executed(UndoCommand::from_closures(|| {}, || {}), name);
|
||||
}
|
||||
let mut index: i64 = 0;
|
||||
assert_eq!(undostack_index(stack, &mut index), 0);
|
||||
assert_eq!(
|
||||
index, K_MAX_UNDO_COMMANDS as i64,
|
||||
stack.done_count(),
|
||||
K_MAX_UNDO_COMMANDS as i64,
|
||||
"pre-executed rows evict at the cap"
|
||||
);
|
||||
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
/// `make_owned` on a stack mutex is what `undostack_init` uses; the
|
||||
/// `CHandle` accessors tolerate empty handles.
|
||||
/// A command value is moved, not copied: moving it into a stack leaves no
|
||||
/// usable alias behind (the compiler enforces this; the assertion pins the
|
||||
/// ownership semantics the old handle shells emulated).
|
||||
#[test]
|
||||
fn empty_chandle_accessors() {
|
||||
let h = oakundo::handle::CHandle::null();
|
||||
assert!(h.is_null());
|
||||
assert!(h.addref.is_none() && h.release.is_none());
|
||||
assert_eq!(h.abi_version, 0);
|
||||
let view = unsafe { oakundo::handle::get::<i32>(&h) };
|
||||
assert!(view.is_none());
|
||||
}
|
||||
|
||||
/// Command-box refcounting: addref/release tolerate NULL ctx, a bumped
|
||||
/// refcount needs a matching release, and a taken multi shell reports
|
||||
/// `E_INVALID` instead of dereferencing a null command pointer.
|
||||
#[test]
|
||||
fn command_box_refcounting_and_taken_multi_shell() {
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: None,
|
||||
undo: None,
|
||||
free_fn: None,
|
||||
};
|
||||
|
||||
let mut cmd = command_init(&vtable, std::ptr::null_mut());
|
||||
assert!(!cmd.ctx.is_null());
|
||||
|
||||
// NULL ctx is a no-op for both refcount callbacks.
|
||||
unsafe { cmd.addref.unwrap()(std::ptr::null_mut()) };
|
||||
unsafe { cmd.release.unwrap()(std::ptr::null_mut()) };
|
||||
|
||||
// addref then two releases: destroyed exactly once at zero.
|
||||
unsafe { cmd.addref.unwrap()(cmd.ctx) };
|
||||
unsafe { cmd.release.unwrap()(cmd.ctx) };
|
||||
unsafe { cmd.release.unwrap()(cmd.ctx) };
|
||||
cmd.ctx = std::ptr::null_mut();
|
||||
|
||||
// A multi command pushed into a stack is taken; the remaining shell
|
||||
// must fail cleanly on child access.
|
||||
let mut stack = undostack_init();
|
||||
let mut multi = command_init_multi();
|
||||
let name = c"m";
|
||||
assert_eq!(undostack_push(stack, multi, name.as_ptr()), 0);
|
||||
let mut out: i32 = -1;
|
||||
assert_eq!(command_multi_child_count(multi, &mut out), OAKUNDO_E_INVALID);
|
||||
command_free(&mut multi);
|
||||
undostack_free(&mut stack);
|
||||
fn command_value_moves_into_stack() {
|
||||
let mut stack = UndoStack::new();
|
||||
stack.push(UndoCommand::from_closures(|| {}, || {}), "A");
|
||||
stack.push(UndoCommand::from_closures(|| {}, || {}), "B");
|
||||
assert_eq!(stack.command_count(), 3);
|
||||
assert!(stack.can_undo());
|
||||
stack.undo().unwrap();
|
||||
assert!(stack.can_redo());
|
||||
assert_eq!(stack.done_count(), 2);
|
||||
assert_eq!(stack.command_name(2).unwrap(), "B"); // undone row still labeled
|
||||
}
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Contract tests for the handle-level command/stack API (the functions
|
||||
//! sunk from the former `ffi.rs`). Each function gets at least one success
|
||||
//! path and one failure path; complex multi-command and stack behavior is
|
||||
//! exercised as a matrix. The expected semantics are pinned by the C++
|
||||
//! module (`src/undo/src`, unchanged).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::{c_char, c_int, c_void, CString};
|
||||
|
||||
use oakundo::error::{OAKUNDO_E_INVALID, OAKUNDO_E_NOT_FOUND, OAKUNDO_OK};
|
||||
use oakundo::handle::CHandle;
|
||||
use oakundo::handle::OAKUNDO_ABI_VERSION;
|
||||
use oakundo::undocommand::{
|
||||
command_free, command_init, command_init_multi, command_multi_add_child, command_multi_child,
|
||||
command_multi_child_count, command_redo_now, command_undo_now, OakUndoCommandVtable,
|
||||
};
|
||||
use oakundo::undostack::{
|
||||
undostack_can_redo, undostack_can_undo, undostack_clear, undostack_command_is_done,
|
||||
undostack_command_text, undostack_count, undostack_free, undostack_index, undostack_init,
|
||||
undostack_jump, undostack_push, undostack_push_pre_executed, undostack_redo, undostack_undo,
|
||||
};
|
||||
|
||||
/// Shared event recorder driven through vtable callbacks.
|
||||
struct Trace {
|
||||
events: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Per-command callback payload: a name and a shared trace.
|
||||
struct Probe {
|
||||
name: &'static str,
|
||||
trace: *const Trace,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn probe_redo(u: *mut c_void) {
|
||||
let p = unsafe { &mut *(u as *mut Probe) };
|
||||
let trace = unsafe { &*p.trace };
|
||||
trace.events.borrow_mut().push(format!("redo:{}", p.name));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn probe_undo(u: *mut c_void) {
|
||||
let p = unsafe { &mut *(u as *mut Probe) };
|
||||
let trace = unsafe { &*p.trace };
|
||||
trace.events.borrow_mut().push(format!("undo:{}", p.name));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn probe_free(u: *mut c_void) {
|
||||
let p = unsafe { &mut *(u as *mut Probe) };
|
||||
let trace = unsafe { &*p.trace };
|
||||
trace.events.borrow_mut().push(format!("free:{}", p.name));
|
||||
}
|
||||
|
||||
/// Snapshot of the recorded events, in order.
|
||||
fn events(trace: &Trace) -> Vec<String> {
|
||||
trace.events.borrow().clone()
|
||||
}
|
||||
|
||||
/// A fresh trace plus three named probes (`a`, `b`, `c`) pointing at it.
|
||||
fn setup() -> (Box<Trace>, Vec<Probe>) {
|
||||
let trace = Box::new(Trace {
|
||||
events: RefCell::new(Vec::new()),
|
||||
});
|
||||
let ptr = &*trace as *const Trace;
|
||||
let mut probes = Vec::new();
|
||||
for name in ["a", "b", "c"] {
|
||||
probes.push(Probe { name, trace: ptr });
|
||||
}
|
||||
(trace, probes)
|
||||
}
|
||||
|
||||
/// A vtable-backed command handle whose callbacks record into `probe`.
|
||||
fn make_cmd(probe: *mut Probe) -> CHandle {
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: Some(probe_redo),
|
||||
undo: Some(probe_undo),
|
||||
free_fn: Some(probe_free),
|
||||
};
|
||||
command_init(&vtable, probe as *mut c_void)
|
||||
}
|
||||
|
||||
/// An empty (all-zero) command handle.
|
||||
fn empty_cmd() -> CHandle {
|
||||
CHandle {
|
||||
ctx: std::ptr::null_mut(),
|
||||
addref: None,
|
||||
release: None,
|
||||
abi_version: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty (all-zero) stack handle.
|
||||
fn empty_stack() -> CHandle {
|
||||
CHandle {
|
||||
ctx: std::ptr::null_mut(),
|
||||
addref: None,
|
||||
release: None,
|
||||
abi_version: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh stack handle (refcount 1) for the calling test.
|
||||
fn new_stack() -> CHandle {
|
||||
undostack_init()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton contract tests (ffi_contract_test.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command lifecycle: `init` returns a refcounted handle, `redo_now` marks
|
||||
/// it done (a second `redo_now` is a no-op), `undo_now` un-done it, and
|
||||
/// `free` runs `free_fn` once and clears `ctx`.
|
||||
#[test]
|
||||
fn command_lifecycle() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
|
||||
assert!(!cmd.ctx.is_null());
|
||||
assert_eq!(cmd.abi_version, OAKUNDO_ABI_VERSION);
|
||||
assert!(cmd.addref.is_some() && cmd.release.is_some());
|
||||
|
||||
assert_eq!(command_redo_now(cmd), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a"]);
|
||||
|
||||
// Idempotent redo.
|
||||
assert_eq!(command_redo_now(cmd), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a"]);
|
||||
|
||||
assert_eq!(command_undo_now(cmd), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a", "undo:a"]);
|
||||
|
||||
// Idempotent undo.
|
||||
assert_eq!(command_undo_now(cmd), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a", "undo:a"]);
|
||||
|
||||
// free destroys once: free_fn runs exactly once and ctx is cleared.
|
||||
command_free(&mut cmd);
|
||||
assert_eq!(events(&trace), vec!["redo:a", "undo:a", "free:a"]);
|
||||
assert!(cmd.ctx.is_null());
|
||||
}
|
||||
|
||||
/// Multi command: `add_child` → `child_count` reflects it; redo runs
|
||||
/// children in order, undo in reverse order.
|
||||
#[test]
|
||||
fn multi_redo_undo_ordering() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut multi = command_init_multi();
|
||||
|
||||
// add children a, b, c.
|
||||
let ca = make_cmd(&mut probes[0] as *mut Probe);
|
||||
let cb = make_cmd(&mut probes[1] as *mut Probe);
|
||||
let cc = make_cmd(&mut probes[2] as *mut Probe);
|
||||
assert_eq!(command_multi_add_child(multi, ca), OAKUNDO_OK);
|
||||
assert_eq!(command_multi_add_child(multi, cb), OAKUNDO_OK);
|
||||
assert_eq!(command_multi_add_child(multi, cc), OAKUNDO_OK);
|
||||
|
||||
// child_count reflects three.
|
||||
let mut count: c_int = 0;
|
||||
assert_eq!(command_multi_child_count(multi, &mut count), OAKUNDO_OK);
|
||||
assert_eq!(count, 3);
|
||||
|
||||
// redo fires in insertion order.
|
||||
assert_eq!(command_redo_now(multi), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a", "redo:b", "redo:c"]);
|
||||
|
||||
// undo fires in reverse order.
|
||||
assert_eq!(command_undo_now(multi), OAKUNDO_OK);
|
||||
assert_eq!(
|
||||
events(&trace),
|
||||
vec!["redo:a", "redo:b", "redo:c", "undo:c", "undo:b", "undo:a"]
|
||||
);
|
||||
|
||||
// Borrowed child handles are released harmlessly before the multi dies.
|
||||
let mut child0 = empty_cmd();
|
||||
assert_eq!(command_multi_child(multi, 0, &mut child0), OAKUNDO_OK);
|
||||
assert!(!child0.ctx.is_null());
|
||||
command_free(&mut child0);
|
||||
|
||||
// free the multi: children are freed (shell only) without double-free.
|
||||
let mut multi_owned = multi;
|
||||
command_free(&mut multi_owned);
|
||||
assert_eq!(
|
||||
events(&trace)
|
||||
.iter()
|
||||
.filter(|e| e.starts_with("free:"))
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
/// Stack: fresh stack has one empty "New/Open Project" command, so
|
||||
/// `can_undo` is 0 and `count` is 1; pushing redoable commands grows
|
||||
/// `count` and makes `can_undo`/`can_redo` track the position.
|
||||
#[test]
|
||||
fn stack_push_undo_redo_queries() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut stack = new_stack();
|
||||
|
||||
let mut count: i64 = 0;
|
||||
let mut value: c_int = 0;
|
||||
assert_eq!(undostack_count(stack, &mut count), OAKUNDO_OK);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(undostack_can_undo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 0);
|
||||
assert_eq!(undostack_can_redo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 0);
|
||||
|
||||
// Push two commands.
|
||||
let name_a = CString::new("A").unwrap();
|
||||
let mut ca = make_cmd(&mut probes[0] as *mut Probe);
|
||||
assert_eq!(undostack_push(stack, ca, name_a.as_ptr()), OAKUNDO_OK);
|
||||
command_free(&mut ca); // non-owning shell now
|
||||
let name_b = CString::new("B").unwrap();
|
||||
let mut cb = make_cmd(&mut probes[1] as *mut Probe);
|
||||
assert_eq!(undostack_push(stack, cb, name_b.as_ptr()), OAKUNDO_OK);
|
||||
command_free(&mut cb);
|
||||
|
||||
assert_eq!(events(&trace), vec!["redo:a", "redo:b"]);
|
||||
assert_eq!(undostack_count(stack, &mut count), OAKUNDO_OK);
|
||||
assert_eq!(count, 3);
|
||||
assert_eq!(undostack_can_undo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 1);
|
||||
assert_eq!(undostack_can_redo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 0);
|
||||
|
||||
// Undo moves B into the redoable tail.
|
||||
assert_eq!(undostack_undo(stack), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a", "redo:b", "undo:b"]);
|
||||
assert_eq!(undostack_can_undo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 1);
|
||||
assert_eq!(undostack_can_redo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 1);
|
||||
|
||||
// Redo restores.
|
||||
assert_eq!(undostack_redo(stack), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a", "redo:b", "undo:b", "redo:b"]);
|
||||
assert_eq!(undostack_can_redo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 0);
|
||||
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
/// `can_redo`/`index` after undo and redo; `jump(0)` clamps to the
|
||||
/// bottom empty command without spinning; `jump` beyond the top is a no-op.
|
||||
#[test]
|
||||
fn stack_jump_clamps() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut stack = new_stack();
|
||||
|
||||
let na = CString::new("A").unwrap();
|
||||
let nb = CString::new("B").unwrap();
|
||||
let nc = CString::new("C").unwrap();
|
||||
let mut ca = make_cmd(&mut probes[0] as *mut Probe);
|
||||
undostack_push(stack, ca, na.as_ptr());
|
||||
let mut cb = make_cmd(&mut probes[1] as *mut Probe);
|
||||
undostack_push(stack, cb, nb.as_ptr());
|
||||
let mut cc = make_cmd(&mut probes[2] as *mut Probe);
|
||||
undostack_push(stack, cc, nc.as_ptr());
|
||||
|
||||
let mut index: i64 = 0;
|
||||
assert_eq!(undostack_index(stack, &mut index), OAKUNDO_OK);
|
||||
assert_eq!(index, 4);
|
||||
|
||||
// jump to the bottom (0): undo all three; negative clamps to 0 too.
|
||||
assert_eq!(undostack_jump(stack, 0), OAKUNDO_OK);
|
||||
assert_eq!(undostack_index(stack, &mut index), OAKUNDO_OK);
|
||||
assert_eq!(index, 1);
|
||||
assert_eq!(
|
||||
events(&trace),
|
||||
vec!["redo:a", "redo:b", "redo:c", "undo:c", "undo:b", "undo:a"]
|
||||
);
|
||||
|
||||
// jump back up to 3: redo a and b (c was already undone to reach 1).
|
||||
assert_eq!(undostack_jump(stack, 3), OAKUNDO_OK);
|
||||
assert_eq!(undostack_index(stack, &mut index), OAKUNDO_OK);
|
||||
assert_eq!(index, 3);
|
||||
|
||||
// jump beyond the top redoes up to the top (index 4), matching the C++
|
||||
// `jump` (undone commands are redoable, so the second loop runs).
|
||||
assert_eq!(undostack_jump(stack, 100), OAKUNDO_OK);
|
||||
assert_eq!(undostack_index(stack, &mut index), OAKUNDO_OK);
|
||||
assert_eq!(index, 4);
|
||||
|
||||
// Negative index clamps to the bottom.
|
||||
assert_eq!(undostack_jump(stack, -5), OAKUNDO_OK);
|
||||
assert_eq!(undostack_index(stack, &mut index), OAKUNDO_OK);
|
||||
assert_eq!(index, 1);
|
||||
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
/// `push_pre_executed` records without redoing (stays undoable); empty
|
||||
/// multi commands are discarded on push.
|
||||
#[test]
|
||||
fn stack_pre_executed_and_empty_multi() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut stack = new_stack();
|
||||
|
||||
// push_pre_executed: no redo callback.
|
||||
let name = CString::new("Pre").unwrap();
|
||||
let mut cp = make_cmd(&mut probes[0] as *mut Probe);
|
||||
assert_eq!(undostack_push_pre_executed(stack, cp, name.as_ptr()), OAKUNDO_OK);
|
||||
command_free(&mut cp);
|
||||
assert_eq!(events(&trace), Vec::<String>::new());
|
||||
|
||||
// The pre-executed command is recorded as done (undoable).
|
||||
let mut done: c_int = 0;
|
||||
assert_eq!(undostack_command_is_done(stack, 1, &mut done), OAKUNDO_OK);
|
||||
assert_eq!(done, 1);
|
||||
|
||||
let mut count: i64 = 0;
|
||||
assert_eq!(undostack_count(stack, &mut count), OAKUNDO_OK);
|
||||
assert_eq!(count, 2);
|
||||
|
||||
// Undoing the pre-executed command still runs its undo callback.
|
||||
assert_eq!(undostack_undo(stack), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["undo:a"]);
|
||||
|
||||
// Empty multi command is discarded on push: count stays 2 (bottom
|
||||
// "New/Open Project" + the undone pre-executed command), not 3.
|
||||
let mut multi = command_init_multi();
|
||||
let name2 = CString::new("Empty").unwrap();
|
||||
assert_eq!(undostack_push(stack, multi, name2.as_ptr()), OAKUNDO_OK);
|
||||
command_free(&mut multi);
|
||||
assert_eq!(undostack_count(stack, &mut count), OAKUNDO_OK);
|
||||
assert_eq!(count, 2, "empty multi is discarded on push");
|
||||
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
/// Every handle-returning export returns `ctx == NULL` on failure and a
|
||||
/// valid handle (`abi_version` stamped) on success; `free(NULL)` /
|
||||
/// `free(empty)` are no-ops across command and stack families.
|
||||
#[test]
|
||||
fn handle_and_free_contract() {
|
||||
let (_trace, mut probes) = setup();
|
||||
|
||||
// init with a NULL vtable → empty handle.
|
||||
let empty = command_init(std::ptr::null(), std::ptr::null_mut());
|
||||
assert!(empty.ctx.is_null());
|
||||
assert_eq!(empty.abi_version, 0);
|
||||
assert!(empty.addref.is_none() && empty.release.is_none());
|
||||
|
||||
// init with a valid vtable → stamped handle.
|
||||
let mut cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
assert!(!cmd.ctx.is_null());
|
||||
assert_eq!(cmd.abi_version, OAKUNDO_ABI_VERSION);
|
||||
|
||||
// init_multi → stamped handle.
|
||||
let mut multi = command_init_multi();
|
||||
assert!(!multi.ctx.is_null());
|
||||
assert_eq!(multi.abi_version, OAKUNDO_ABI_VERSION);
|
||||
|
||||
// init stack → stamped handle.
|
||||
let mut stack = new_stack();
|
||||
assert!(!stack.ctx.is_null());
|
||||
assert_eq!(stack.abi_version, OAKUNDO_ABI_VERSION);
|
||||
|
||||
// free(NULL) is a no-op for both families.
|
||||
command_free(std::ptr::null_mut());
|
||||
undostack_free(std::ptr::null_mut());
|
||||
|
||||
// free(empty handle value) is a no-op.
|
||||
let mut ecmd = empty_cmd();
|
||||
command_free(&mut ecmd);
|
||||
assert!(ecmd.ctx.is_null());
|
||||
let mut estack = empty_stack();
|
||||
undostack_free(&mut estack);
|
||||
assert!(estack.ctx.is_null());
|
||||
|
||||
// free(valid) clears ctx.
|
||||
command_free(&mut multi);
|
||||
assert!(multi.ctx.is_null());
|
||||
command_free(&mut cmd);
|
||||
assert!(cmd.ctx.is_null());
|
||||
undostack_free(&mut stack);
|
||||
assert!(stack.ctx.is_null());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exhaustive per-export success/failure coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn command_redo_undo_now_null_is_invalid() {
|
||||
assert_eq!(command_redo_now(empty_cmd()), OAKUNDO_E_INVALID);
|
||||
assert_eq!(command_undo_now(empty_cmd()), OAKUNDO_E_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_multi_add_child_errors() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let mut multi = command_init_multi();
|
||||
let mut child = make_cmd(&mut probes[0] as *mut Probe);
|
||||
let mut vtable_cmd = make_cmd(&mut probes[1] as *mut Probe);
|
||||
|
||||
// multi is null → E_INVALID.
|
||||
assert_eq!(command_multi_add_child(empty_cmd(), child), OAKUNDO_E_INVALID);
|
||||
// child is null → E_INVALID (nothing taken).
|
||||
assert_eq!(command_multi_add_child(multi, empty_cmd()), OAKUNDO_E_INVALID);
|
||||
// target is a vtable command, not a multi → E_INVALID.
|
||||
assert_eq!(command_multi_add_child(vtable_cmd, child), OAKUNDO_E_INVALID);
|
||||
|
||||
// The child handle still owns its value (never taken), so free it.
|
||||
command_free(&mut child);
|
||||
command_free(&mut vtable_cmd);
|
||||
command_free(&mut multi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_multi_child_count_errors() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let mut multi = command_init_multi();
|
||||
let mut vtable_cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
let mut out: c_int = -1;
|
||||
|
||||
// null out pointer → E_INVALID.
|
||||
assert_eq!(command_multi_child_count(multi, std::ptr::null_mut()), OAKUNDO_E_INVALID);
|
||||
// null multi → E_INVALID.
|
||||
assert_eq!(command_multi_child_count(empty_cmd(), &mut out), OAKUNDO_E_INVALID);
|
||||
// non-multi command → E_INVALID.
|
||||
assert_eq!(command_multi_child_count(vtable_cmd, &mut out), OAKUNDO_E_INVALID);
|
||||
|
||||
command_free(&mut vtable_cmd);
|
||||
command_free(&mut multi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_multi_child_errors() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let mut multi = command_init_multi();
|
||||
let mut vtable_cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
let mut out = empty_cmd();
|
||||
|
||||
// null out pointer → E_INVALID.
|
||||
assert_eq!(command_multi_child(multi, 0, std::ptr::null_mut()), OAKUNDO_E_INVALID);
|
||||
// null multi → E_INVALID.
|
||||
assert_eq!(command_multi_child(empty_cmd(), 0, &mut out), OAKUNDO_E_INVALID);
|
||||
// non-multi command → E_INVALID.
|
||||
assert_eq!(command_multi_child(vtable_cmd, 0, &mut out), OAKUNDO_E_INVALID);
|
||||
// empty multi, negative index → E_NOT_FOUND.
|
||||
assert_eq!(command_multi_child(multi, -1, &mut out), OAKUNDO_E_NOT_FOUND);
|
||||
// empty multi, positive OOB → E_NOT_FOUND.
|
||||
assert_eq!(command_multi_child(multi, 5, &mut out), OAKUNDO_E_NOT_FOUND);
|
||||
|
||||
command_free(&mut vtable_cmd);
|
||||
command_free(&mut multi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_multi_child_success_borrowed() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut multi = command_init_multi();
|
||||
let mut ca = make_cmd(&mut probes[0] as *mut Probe);
|
||||
command_multi_add_child(multi, ca);
|
||||
|
||||
let mut child = empty_cmd();
|
||||
assert_eq!(command_multi_child(multi, 0, &mut child), OAKUNDO_OK);
|
||||
assert!(!child.ctx.is_null());
|
||||
// A borrowed child is not independently owned: freeing it frees only the
|
||||
// shell and must not free the child the multi still owns.
|
||||
command_free(&mut child);
|
||||
assert!(child.ctx.is_null());
|
||||
|
||||
// Redo through the multi still works and no child was freed.
|
||||
assert_eq!(command_redo_now(multi), OAKUNDO_OK);
|
||||
assert_eq!(events(&trace), vec!["redo:a"]);
|
||||
|
||||
command_free(&mut multi);
|
||||
assert_eq!(events(&trace), vec!["redo:a", "free:a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_free_fires_exactly_once() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
|
||||
// free → one free callback.
|
||||
command_free(&mut cmd);
|
||||
assert_eq!(events(&trace), vec!["free:a"]);
|
||||
|
||||
// free again on a cleared handle → no-op.
|
||||
command_free(&mut cmd);
|
||||
assert_eq!(events(&trace), vec!["free:a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_free_null_and_empty() {
|
||||
let mut stack = empty_stack();
|
||||
undostack_free(std::ptr::null_mut());
|
||||
undostack_free(&mut stack);
|
||||
assert!(stack.ctx.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_push_errors() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut stack = new_stack();
|
||||
let mut cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
let name = CString::new("A").unwrap();
|
||||
|
||||
// null command → E_INVALID; stack untouched, command still owned.
|
||||
assert_eq!(undostack_push(stack, empty_cmd(), name.as_ptr()), OAKUNDO_E_INVALID);
|
||||
// empty stack → E_INVALID; command NOT drained (still owns its value).
|
||||
assert_eq!(undostack_push(empty_stack(), cmd, name.as_ptr()), OAKUNDO_E_INVALID);
|
||||
assert_eq!(
|
||||
events(&trace),
|
||||
Vec::<String>::new(),
|
||||
"no callbacks ran on failed push"
|
||||
);
|
||||
|
||||
// NULL name is accepted as an empty label → OK.
|
||||
assert_eq!(undostack_push(stack, cmd, std::ptr::null()), OAKUNDO_OK);
|
||||
|
||||
command_free(&mut cmd); // now a non-owning shell
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_push_pre_executed_errors() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let mut stack = new_stack();
|
||||
let mut cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
let name = CString::new("A").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
undostack_push_pre_executed(empty_stack(), cmd, name.as_ptr()),
|
||||
OAKUNDO_E_INVALID
|
||||
);
|
||||
// command was not drained; still owned, so free it.
|
||||
command_free(&mut cmd);
|
||||
assert_eq!(
|
||||
undostack_push_pre_executed(stack, empty_cmd(), name.as_ptr()),
|
||||
OAKUNDO_E_INVALID
|
||||
);
|
||||
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_undo_redo_errors() {
|
||||
let mut stack = new_stack();
|
||||
assert_eq!(undostack_undo(empty_stack()), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_redo(empty_stack()), OAKUNDO_E_INVALID);
|
||||
// Valid stack, but nothing to undo: still OK (no-op).
|
||||
assert_eq!(undostack_undo(stack), OAKUNDO_OK);
|
||||
assert_eq!(undostack_redo(stack), OAKUNDO_OK);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_jump_clear_errors() {
|
||||
let mut stack = new_stack();
|
||||
assert_eq!(undostack_jump(empty_stack(), 3), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_clear(empty_stack()), OAKUNDO_E_INVALID);
|
||||
|
||||
// Clear on a valid stack resets to the empty bottom command.
|
||||
let mut count: i64 = 0;
|
||||
undostack_clear(stack);
|
||||
assert_eq!(undostack_count(stack, &mut count), OAKUNDO_OK);
|
||||
assert_eq!(count, 1);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_can_undo_redo_errors() {
|
||||
let mut stack = new_stack();
|
||||
let mut value: c_int = 0;
|
||||
assert_eq!(undostack_can_undo(empty_stack(), &mut value), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_can_redo(empty_stack(), &mut value), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_can_undo(stack, std::ptr::null_mut()), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_can_redo(stack, std::ptr::null_mut()), OAKUNDO_E_INVALID);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_count_index_errors() {
|
||||
let mut stack = new_stack();
|
||||
let mut out: i64 = 0;
|
||||
assert_eq!(undostack_count(empty_stack(), &mut out), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_index(empty_stack(), &mut out), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_count(stack, std::ptr::null_mut()), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_index(stack, std::ptr::null_mut()), OAKUNDO_E_INVALID);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_command_text_two_stage_and_errors() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let mut stack = new_stack();
|
||||
let name = CString::new("MyAction").unwrap();
|
||||
let mut cmd = make_cmd(&mut probes[0] as *mut Probe);
|
||||
undostack_push(stack, cmd, name.as_ptr());
|
||||
|
||||
// Failure: empty stack → E_INVALID.
|
||||
assert_eq!(undostack_command_text(empty_stack(), 1, std::ptr::null_mut(), 0), OAKUNDO_E_INVALID);
|
||||
// Failure: OOB row (positive) → E_NOT_FOUND.
|
||||
assert_eq!(undostack_command_text(stack, 5, std::ptr::null_mut(), 0), OAKUNDO_E_NOT_FOUND);
|
||||
// Failure: negative row → E_NOT_FOUND.
|
||||
assert_eq!(undostack_command_text(stack, -1, std::ptr::null_mut(), 0), OAKUNDO_E_NOT_FOUND);
|
||||
|
||||
// Stage one: null buffer returns the required size ("MyAction" + NUL).
|
||||
let required = undostack_command_text(stack, 1, std::ptr::null_mut(), 0);
|
||||
assert_eq!(required, "MyAction".len() as c_int + 1);
|
||||
|
||||
// Stage two: a buffer of that size is populated with a NUL-terminated
|
||||
// string and the required size is returned again.
|
||||
let mut buf = vec![0u8; required as usize];
|
||||
let ret = undostack_command_text(stack, 1, buf.as_mut_ptr() as *mut c_char, required);
|
||||
assert_eq!(ret, required);
|
||||
let actual = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr() as *const c_char) };
|
||||
assert_eq!(actual.to_bytes(), b"MyAction");
|
||||
|
||||
// A too-small buffer is safely truncated (NUL-terminated).
|
||||
let mut small = vec![0xffu8; 3];
|
||||
undostack_command_text(stack, 1, small.as_mut_ptr() as *mut c_char, 3);
|
||||
assert_eq!(small, [b'M', b'y', 0]);
|
||||
|
||||
command_free(&mut cmd);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_command_is_done_errors() {
|
||||
let mut stack = new_stack();
|
||||
let mut value: c_int = 0;
|
||||
assert_eq!(undostack_command_is_done(empty_stack(), 0, &mut value), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_command_is_done(stack, 0, std::ptr::null_mut()), OAKUNDO_E_INVALID);
|
||||
assert_eq!(undostack_command_is_done(stack, 5, &mut value), OAKUNDO_E_NOT_FOUND);
|
||||
assert_eq!(undostack_command_is_done(stack, -1, &mut value), OAKUNDO_E_NOT_FOUND);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undostack_push_drops_redoable_tail() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut stack = new_stack();
|
||||
let na = CString::new("A").unwrap();
|
||||
let nb = CString::new("B").unwrap();
|
||||
let mut ca = make_cmd(&mut probes[0] as *mut Probe);
|
||||
undostack_push(stack, ca, na.as_ptr());
|
||||
let mut cb = make_cmd(&mut probes[1] as *mut Probe);
|
||||
undostack_push(stack, cb, nb.as_ptr());
|
||||
|
||||
// Undo B, then push C → the redoable tail is dropped.
|
||||
assert_eq!(undostack_undo(stack), OAKUNDO_OK);
|
||||
let mut value: c_int = 0;
|
||||
assert_eq!(undostack_can_redo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 1);
|
||||
|
||||
let nc = CString::new("C").unwrap();
|
||||
let mut cc = make_cmd(&mut probes[2] as *mut Probe);
|
||||
assert_eq!(undostack_push(stack, cc, nc.as_ptr()), OAKUNDO_OK);
|
||||
|
||||
assert_eq!(undostack_can_redo(stack, &mut value), OAKUNDO_OK);
|
||||
assert_eq!(value, 0, "pushing drops the redoable tail");
|
||||
assert_eq!(
|
||||
events(&trace),
|
||||
vec!["redo:a", "redo:b", "undo:b", "free:b", "redo:c"]
|
||||
);
|
||||
|
||||
command_free(&mut ca);
|
||||
command_free(&mut cb);
|
||||
command_free(&mut cc);
|
||||
undostack_free(&mut stack);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Tests for the refcounted-handle scaffolding (`handle.rs`).
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use oakundo::error::{Result, OAKUNDO_E_FAILED, OAKUNDO_E_INVALID, OAKUNDO_OK};
|
||||
use oakundo::handle::{get, guard, guard_handle, guard_void, make_borrowed, make_owned, CHandle};
|
||||
|
||||
/// A value whose `Drop` signals through a shared counter.
|
||||
struct DropProbe {
|
||||
counter: Arc<AtomicI32>,
|
||||
}
|
||||
|
||||
impl DropProbe {
|
||||
fn new() -> (Self, Arc<AtomicI32>) {
|
||||
let counter = Arc::new(AtomicI32::new(1));
|
||||
(
|
||||
DropProbe {
|
||||
counter: counter.clone(),
|
||||
},
|
||||
counter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DropProbe {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_handle_is_all_zero() {
|
||||
let h = CHandle::null();
|
||||
assert!(h.is_null());
|
||||
assert!(h.ctx.is_null());
|
||||
assert!(h.addref.is_none());
|
||||
assert!(h.release.is_none());
|
||||
assert_eq!(h.abi_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_owned_counts_and_releases() {
|
||||
let (value, counter) = DropProbe::new();
|
||||
let h = make_owned(value);
|
||||
|
||||
assert!(!h.is_null());
|
||||
assert_eq!(h.abi_version, oakundo::handle::OAKUNDO_ABI_VERSION);
|
||||
|
||||
// addref bumps the count; a second release does not double-drop.
|
||||
let addref = h.addref.unwrap();
|
||||
let release = h.release.unwrap();
|
||||
unsafe { addref(h.ctx) };
|
||||
unsafe { release(h.ctx) };
|
||||
assert_eq!(
|
||||
counter.load(Ordering::SeqCst),
|
||||
1,
|
||||
"still alive after one release"
|
||||
);
|
||||
|
||||
// Final release drops the box.
|
||||
unsafe { release(h.ctx) };
|
||||
assert_eq!(
|
||||
counter.load(Ordering::SeqCst),
|
||||
0,
|
||||
"dropped at refcount zero"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_owned_get_round_trips() {
|
||||
let h = make_owned(42i32);
|
||||
let ch = &h;
|
||||
let v = unsafe { get::<i32>(ch) };
|
||||
assert_eq!(v, Some(&42));
|
||||
|
||||
let empty = CHandle::null();
|
||||
assert_eq!(unsafe { get::<i32>(&empty) }, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_release_does_not_free_pointee() {
|
||||
let (mut value, counter) = DropProbe::new();
|
||||
let b = unsafe { make_borrowed(&mut value as *mut DropProbe) };
|
||||
|
||||
let release = b.release.unwrap();
|
||||
unsafe { release(b.ctx) };
|
||||
assert_eq!(
|
||||
counter.load(Ordering::SeqCst),
|
||||
1,
|
||||
"borrowed release frees only the shell"
|
||||
);
|
||||
|
||||
// The owned value is still usable and is dropped normally.
|
||||
drop(value);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_success_error_and_panic() {
|
||||
fn ok() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn invalid() -> Result<()> {
|
||||
Err(oakundo::error::Error::Invalid)
|
||||
}
|
||||
fn panic() -> Result<()> {
|
||||
panic!("boom")
|
||||
}
|
||||
assert_eq!(guard(ok), OAKUNDO_OK);
|
||||
assert_eq!(guard(invalid), OAKUNDO_E_INVALID);
|
||||
assert_eq!(guard(panic), OAKUNDO_E_FAILED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_handle_returns_value_or_null() {
|
||||
let h = guard_handle(|| Ok(make_owned(5i32)));
|
||||
assert!(!h.is_null());
|
||||
assert_eq!(h.abi_version, oakundo::handle::OAKUNDO_ABI_VERSION);
|
||||
|
||||
let on_err = guard_handle(|| Err(oakundo::error::Error::NotFound));
|
||||
assert!(on_err.is_null());
|
||||
|
||||
let on_panic = guard_handle(|| -> Result<CHandle> { panic!("boom") });
|
||||
assert!(on_panic.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_void_swallows_panic() {
|
||||
let mut ran = false;
|
||||
guard_void(|| ran = true);
|
||||
assert!(ran);
|
||||
|
||||
guard_void(|| panic!("swallowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addref_release_null_ctx_is_noop() {
|
||||
let h = make_owned(7i32);
|
||||
unsafe { (h.addref.unwrap())(std::ptr::null_mut()) };
|
||||
unsafe { (h.release.unwrap())(std::ptr::null_mut()) };
|
||||
assert!(!h.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_handle_binds_to_correct_type() {
|
||||
// The borrowed handle boxes a `*mut T`; `get` for that type sees the
|
||||
// raw pointer value.
|
||||
let mut value = 99u64;
|
||||
let h = unsafe { make_borrowed(&mut value as *mut u64) };
|
||||
let ch = &h;
|
||||
let p = unsafe { get::<*mut u64>(ch) }.unwrap();
|
||||
assert_eq!(unsafe { **p }, 99);
|
||||
}
|
||||
|
||||
/// `*mut c_void` helper used to drive callbacks directly in one test.
|
||||
#[allow(dead_code)]
|
||||
fn _as_void<T>(p: *mut T) -> *mut c_void {
|
||||
p as *mut c_void
|
||||
}
|
||||
@@ -17,66 +17,52 @@
|
||||
//! Safe-layer behavior matrix for `UndoCommand` / `UndoStack`
|
||||
//! (mirrors `src/undo/src/undostack.cpp` / `undocommand.cpp`).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use oakundo::error::Error;
|
||||
use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand};
|
||||
use oakundo::undocommand::UndoCommand;
|
||||
use oakundo::undostack::{EmptyCommand, UndoStack, K_MAX_UNDO_COMMANDS};
|
||||
|
||||
/// Shared event recorder driven through vtable callbacks.
|
||||
struct Trace {
|
||||
events: RefCell<Vec<String>>,
|
||||
/// Shared event recorder; commands record `redo:<name>` / `undo:<name>`
|
||||
/// through their closures.
|
||||
type Trace = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
fn new_trace() -> Trace {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Per-command callback payload: a name and a shared trace.
|
||||
struct Probe {
|
||||
name: &'static str,
|
||||
trace: *const Trace,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn probe_redo(u: *mut c_void) {
|
||||
let p = unsafe { &mut *(u as *mut Probe) };
|
||||
let trace = unsafe { &*p.trace };
|
||||
trace.events.borrow_mut().push(format!("redo:{}", p.name));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn probe_undo(u: *mut c_void) {
|
||||
let p = unsafe { &mut *(u as *mut Probe) };
|
||||
let trace = unsafe { &*p.trace };
|
||||
trace.events.borrow_mut().push(format!("undo:{}", p.name));
|
||||
}
|
||||
|
||||
/// A vtable-backed command whose callbacks record into `probe`.
|
||||
fn trace_cmd(probe: &mut Probe) -> UndoCommand {
|
||||
let vtable = OakUndoCommandVtable {
|
||||
redo: Some(probe_redo),
|
||||
undo: Some(probe_undo),
|
||||
free_fn: None,
|
||||
};
|
||||
UndoCommand::from_vtable(vtable, probe as *mut Probe as *mut c_void)
|
||||
/// A closure-backed command whose redo/undo record into `trace`.
|
||||
fn trace_cmd(name: &'static str, trace: &Trace) -> UndoCommand {
|
||||
let (t_redo, t_undo) = (trace.clone(), trace.clone());
|
||||
let (name_redo, name_undo) = (name.to_string(), name.to_string());
|
||||
UndoCommand::from_closures(
|
||||
move || t_redo.lock().unwrap().push(format!("redo:{name_redo}")),
|
||||
move || t_undo.lock().unwrap().push(format!("undo:{name_undo}")),
|
||||
)
|
||||
}
|
||||
|
||||
fn events(trace: &Trace) -> Vec<String> {
|
||||
trace.events.borrow().clone()
|
||||
trace.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn setup() -> (Box<Trace>, Vec<Probe>) {
|
||||
let trace = Box::new(Trace {
|
||||
events: RefCell::new(Vec::new()),
|
||||
});
|
||||
let ptr = &*trace as *const Trace;
|
||||
let mut probes = Vec::new();
|
||||
for name in ["a", "b", "c"] {
|
||||
probes.push(Probe { name, trace: ptr });
|
||||
/// A `Drop` guard that records `free:<name>` exactly once — the new-API
|
||||
/// analogue of the C-ABI `free_fn` callback (dropping a command drops its
|
||||
/// boxed closures, which drop their captures).
|
||||
struct FreeProbe {
|
||||
name: &'static str,
|
||||
trace: Trace,
|
||||
}
|
||||
|
||||
impl Drop for FreeProbe {
|
||||
fn drop(&mut self) {
|
||||
self.trace.lock().unwrap().push(format!("free:{}", self.name));
|
||||
}
|
||||
(trace, probes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_redo_undo_lifecycle() {
|
||||
let (trace, mut probes) = setup();
|
||||
let mut cmd = trace_cmd(&mut probes[0]);
|
||||
let trace = new_trace();
|
||||
let mut cmd = trace_cmd("a", &trace);
|
||||
|
||||
assert!(!cmd.is_done());
|
||||
assert!(cmd.has_prepared());
|
||||
@@ -104,17 +90,32 @@ fn command_redo_undo_lifecycle() {
|
||||
assert_eq!(events(&trace), vec!["redo:a", "undo:a", "redo:a", "undo:a"]);
|
||||
|
||||
// set_done marks executed without running anything.
|
||||
let mut probe = Probe {
|
||||
name: "x",
|
||||
trace: probes[0].trace,
|
||||
};
|
||||
let mut cmd2 = trace_cmd(&mut probe);
|
||||
let mut cmd2 = trace_cmd("x", &trace);
|
||||
cmd2.set_done(true);
|
||||
assert!(cmd2.is_done());
|
||||
cmd2.redo_now(); // no-op (already done)
|
||||
assert_eq!(events(&trace), vec!["redo:a", "undo:a", "redo:a", "undo:a"]);
|
||||
}
|
||||
|
||||
/// Dropping a command runs its destruction exactly once (the C-ABI
|
||||
/// `free_fn` exactly-once contract, now via `Drop`).
|
||||
#[test]
|
||||
fn command_drop_frees_exactly_once() {
|
||||
let trace = new_trace();
|
||||
let probe = FreeProbe {
|
||||
name: "a",
|
||||
trace: trace.clone(),
|
||||
};
|
||||
let cmd = UndoCommand::from_closures(move || {}, move || drop(&probe));
|
||||
|
||||
drop(cmd);
|
||||
assert_eq!(events(&trace), vec!["free:a"]);
|
||||
|
||||
// Re-dropping an already-dropped value is impossible by construction;
|
||||
// a second drop of a clone of the event log stays empty.
|
||||
assert_eq!(events(&trace), vec!["free:a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_command_is_noop() {
|
||||
let mut cmd = EmptyCommand::new();
|
||||
@@ -128,11 +129,11 @@ fn empty_command_is_noop() {
|
||||
|
||||
#[test]
|
||||
fn multi_redo_undo_ordering() {
|
||||
let (trace, mut probes) = setup();
|
||||
let trace = new_trace();
|
||||
let mut multi = UndoCommand::multi();
|
||||
multi.multi_add_child(trace_cmd(&mut probes[0]));
|
||||
multi.multi_add_child(trace_cmd(&mut probes[1]));
|
||||
multi.multi_add_child(trace_cmd(&mut probes[2]));
|
||||
multi.multi_add_child(trace_cmd("a", &trace));
|
||||
multi.multi_add_child(trace_cmd("b", &trace));
|
||||
multi.multi_add_child(trace_cmd("c", &trace));
|
||||
|
||||
assert_eq!(multi.multi_child_count(), 3);
|
||||
// Children are reachable and named.
|
||||
@@ -154,18 +155,19 @@ fn multi_redo_undo_ordering() {
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn multi_add_child_on_vtable_panics() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let mut vtable_cmd = trace_cmd(&mut probes[0]);
|
||||
vtable_cmd.multi_add_child(trace_cmd(&mut probes[1]));
|
||||
fn multi_add_child_on_plain_command_panics() {
|
||||
let trace = new_trace();
|
||||
let mut cmd = trace_cmd("a", &trace);
|
||||
cmd.multi_add_child(trace_cmd("b", &trace));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_child_helpers_on_non_multi() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let mut vtable_cmd = trace_cmd(&mut probes[0]);
|
||||
assert_eq!(vtable_cmd.multi_child_count(), 0);
|
||||
assert!(matches!(vtable_cmd.multi_child(0), Err(Error::Invalid)));
|
||||
fn multi_child_helpers_on_plain_command() {
|
||||
let trace = new_trace();
|
||||
let mut cmd = trace_cmd("a", &trace);
|
||||
assert_eq!(cmd.multi_child_count(), 0);
|
||||
assert!(matches!(cmd.multi_child(0), Err(Error::Invalid)));
|
||||
assert!(matches!(cmd.multi_child_mut(0), Err(Error::Invalid)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,11 +183,11 @@ fn stack_new_has_empty_bottom() {
|
||||
|
||||
#[test]
|
||||
fn stack_push_undo_redo_branch() {
|
||||
let (trace, mut probes) = setup();
|
||||
let trace = new_trace();
|
||||
let mut s = UndoStack::new();
|
||||
|
||||
s.push(trace_cmd(&mut probes[0]), "A");
|
||||
s.push(trace_cmd(&mut probes[1]), "B");
|
||||
s.push(trace_cmd("a", &trace), "A");
|
||||
s.push(trace_cmd("b", &trace), "B");
|
||||
assert_eq!(s.command_count(), 3);
|
||||
assert!(s.can_undo());
|
||||
assert!(!s.can_redo());
|
||||
@@ -207,7 +209,7 @@ fn stack_push_undo_redo_branch() {
|
||||
// Undo then push drops the redoable tail.
|
||||
s.undo().unwrap();
|
||||
assert!(s.can_redo());
|
||||
s.push(trace_cmd(&mut probes[2]), "C");
|
||||
s.push(trace_cmd("c", &trace), "C");
|
||||
assert!(!s.can_redo(), "pushing drops redoable tail");
|
||||
assert_eq!(s.command_count(), 3);
|
||||
assert_eq!(s.command_name(2).unwrap(), "C");
|
||||
@@ -226,11 +228,11 @@ fn stack_undo_redo_noop_when_invalid() {
|
||||
|
||||
#[test]
|
||||
fn stack_jump_clamps_and_lands() {
|
||||
let (trace, mut probes) = setup();
|
||||
let trace = new_trace();
|
||||
let mut s = UndoStack::new();
|
||||
s.push(trace_cmd(&mut probes[0]), "A");
|
||||
s.push(trace_cmd(&mut probes[1]), "B");
|
||||
s.push(trace_cmd(&mut probes[2]), "C");
|
||||
s.push(trace_cmd("a", &trace), "A");
|
||||
s.push(trace_cmd("b", &trace), "B");
|
||||
s.push(trace_cmd("c", &trace), "C");
|
||||
assert_eq!(s.done_count(), 4); // empty + A + B + C
|
||||
|
||||
// Jump back to just the empty command (clamps negative to 0).
|
||||
@@ -274,9 +276,9 @@ fn stack_discards_empty_multi() {
|
||||
|
||||
#[test]
|
||||
fn stack_push_pre_executed_skips_redo() {
|
||||
let (trace, mut probes) = setup();
|
||||
let trace = new_trace();
|
||||
let mut s = UndoStack::new();
|
||||
s.push_pre_executed(trace_cmd(&mut probes[0]), "A");
|
||||
s.push_pre_executed(trace_cmd("a", &trace), "A");
|
||||
assert_eq!(
|
||||
events(&trace),
|
||||
Vec::<String>::new(),
|
||||
@@ -293,9 +295,9 @@ fn stack_push_pre_executed_skips_redo() {
|
||||
|
||||
#[test]
|
||||
fn stack_clear_resets_to_bottom() {
|
||||
let (trace, mut probes) = setup();
|
||||
let trace = new_trace();
|
||||
let mut s = UndoStack::new();
|
||||
s.push(trace_cmd(&mut probes[0]), "A");
|
||||
s.push(trace_cmd("a", &trace), "A");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.command_count(), 2);
|
||||
|
||||
@@ -308,15 +310,10 @@ fn stack_clear_resets_to_bottom() {
|
||||
|
||||
#[test]
|
||||
fn stack_caps_at_k_max() {
|
||||
let (_trace, mut probes) = setup();
|
||||
let trace = new_trace();
|
||||
let mut s = UndoStack::new();
|
||||
for i in 0..(K_MAX_UNDO_COMMANDS + 20) {
|
||||
let mut probe = Probe {
|
||||
name: "x",
|
||||
trace: probes[0].trace,
|
||||
};
|
||||
let _ = &mut probe;
|
||||
s.push(trace_cmd(&mut probes[i % 3]), &format!("cmd{i}"));
|
||||
s.push(trace_cmd("x", &trace), &format!("cmd{i}"));
|
||||
}
|
||||
assert_eq!(s.command_count() as usize, K_MAX_UNDO_COMMANDS);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user