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:
2026-08-19 00:41:49 +08:00
parent e0aa597f1e
commit 194d761ade
28 changed files with 409 additions and 2482 deletions
+2 -48
View File
@@ -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
+3 -34
View File
@@ -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));
+5 -49
View File
@@ -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 路径;数值族输入)。
+8 -57
View File
@@ -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
+9 -8
View File
@@ -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)]
+6 -6
View File
@@ -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)
}
+3 -3
View File
@@ -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)
}
+14 -88
View File
@@ -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)
}
+10 -10
View File
@@ -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)
}
+4 -4
View File
@@ -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)
}
+10 -10
View File
@@ -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)
}
+5 -5
View File
@@ -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)
}
+4 -4
View File
@@ -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)
}
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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());
+1 -1
View File
@@ -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());
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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());
-160
View File
@@ -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
View File
@@ -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;
+88 -442
View File
@@ -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();
})
}
+9 -213
View File
@@ -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(())
})
})
}
+33 -156
View File
@@ -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
}
-681
View File
@@ -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);
}
-177
View File
@@ -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
}
+78 -81
View File
@@ -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);
}