refactor: sink facade glue into modules (M14 R1)

- oakundo::global: the process-global undo stack, grouping and a
  command observer API; facade undo.rs becomes a thin forwarder
- oakstorage::writethrough: the binding table, snapshot thread, flush
  and config resolution; it subscribes to oakundo's observer itself
- the remaining non-forwarding facade logic (TaskMeta, effect chains,
  timeline composites, RendererBox, exporter path) is documented as
  facade-owned with reasons
- facade exports unchanged; full suite stays green (the one
  render_manager_not_initialized failure is pre-existing on the base
  commit)
This commit is contained in:
2026-08-16 20:37:45 +08:00
parent f59c7c8476
commit 8a2e45225f
9 changed files with 1096 additions and 674 deletions
+14
View File
@@ -66,6 +66,20 @@ impl Error {
Error::NoMem => OAKUNDO_E_NOMEM,
}
}
/// Recover the variant for a raw module return code (the numeric value
/// round-trips through [`Error::code`]). Used by the handle-level
/// composers whose sub-calls report plain `c_int` codes.
pub fn from_code(code: i32) -> Error {
match code {
OAKUNDO_E_INVALID => Error::Invalid,
OAKUNDO_E_STATE => Error::State,
OAKUNDO_E_FAILED => Error::Failed("operation failed".into()),
OAKUNDO_E_NOT_FOUND => Error::NotFound,
OAKUNDO_E_NOMEM => Error::NoMem,
_ => Error::Failed(format!("module error code {code}")),
}
}
}
#[cfg(test)]
+425
View File
@@ -0,0 +1,425 @@
// 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/>.
//! The process-wide undo stack, undo groups and the command-success
//! observer hook (M14 R1: sunk from the engine facade's `undo.rs`).
//!
//! 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
//! lets downstream modules (the oakstorage write-through session manager)
//! subscribe to "a command was recorded" notifications without a facade
//! round-trip.
use std::ffi::{c_char, c_int, c_void};
use std::sync::{Mutex, OnceLock};
use crate::error::{Error, Result};
use crate::handle::CHandle;
use crate::undocommand::{
command_free, command_init_multi, command_multi_add_child, command_multi_child,
command_multi_child_count, command_redo_now, command_undo_now,
};
use crate::undostack::{
undostack_can_redo, undostack_can_undo, undostack_clear, undostack_command_is_done,
undostack_command_text, undostack_count, undostack_index, undostack_init, undostack_jump,
undostack_push, undostack_push_pre_executed,
};
/// The process-wide undo stack handle (`OakUndoStack`), created lazily
/// on first use and kept for the process lifetime.
fn global_stack() -> &'static CHandle {
static STACK: OnceLock<CHandle> = OnceLock::new();
STACK.get_or_init(|| undostack_init())
}
/// Stable opaque token for the engine's `oakengine_undo_handle` export:
/// the stack handle's `ctx` pointer (never dereferenced by callers; lives
/// for the process).
pub fn stack_token() -> *mut c_void {
global_stack().ctx
}
/// Borrowed copy of the process-wide stack handle (for the module-level
/// queries below).
fn stack() -> CHandle {
*global_stack()
}
/// Read a NUL-terminated C string; `NULL` yields an empty string.
fn read_name(name: *const c_char) -> String {
if name.is_null() {
String::new()
} else {
// SAFETY: the caller guarantees a valid NUL-terminated string.
unsafe { std::ffi::CStr::from_ptr(name) }
.to_string_lossy()
.into_owned()
}
}
// ---------------------------------------------------------------------------
// Command-success observers
// ---------------------------------------------------------------------------
/// A callback invoked after a command is successfully recorded on the
/// process-wide stack (a stack push, a group end or a jump).
pub type CommandObserver = fn();
static OBSERVERS: OnceLock<Mutex<Vec<CommandObserver>>> = OnceLock::new();
fn observers() -> &'static Mutex<Vec<CommandObserver>> {
OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
}
/// Register a command-success observer. The callback runs after the stack
/// mutation is complete, outside the stack/group locks; multiple observers
/// are supported and run in registration order. There is no un-registration
/// API — observers are process-lifetime, mirroring the global stack itself.
pub fn add_observer(f: CommandObserver) {
observers().lock().unwrap_or_else(|e| e.into_inner()).push(f);
}
/// Invoke every registered observer (called on the stack-path push, the
/// group end and the jump success).
fn notify_observers() {
let callbacks: Vec<CommandObserver> = observers()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
for cb in callbacks {
cb();
}
}
// ---------------------------------------------------------------------------
// Undo group
// ---------------------------------------------------------------------------
/// The currently open undo group (a multi command handle) plus its name.
struct OpenGroup {
/// Multi command handle; owned by this state until end/abort.
multi: CHandle,
/// Group label.
#[allow(dead_code)]
name: String,
}
static GROUP: Mutex<Option<OpenGroup>> = Mutex::new(None);
fn group_lock() -> std::sync::MutexGuard<'static, Option<OpenGroup>> {
GROUP.lock().unwrap_or_else(|e| e.into_inner())
}
/// Start collecting commands into a group. [`Error::State`] when a group
/// is already open.
pub fn group_begin(name: *const c_char) -> Result<()> {
let mut g = group_lock();
if g.is_some() {
return Err(Error::State);
}
let multi = command_init_multi();
if multi.is_null() {
return Err(Error::NoMem);
}
*g = Some(OpenGroup {
multi,
name: read_name(name),
});
Ok(())
}
/// Close the group and push it as one entry. An empty group is discarded
/// (no undo entry). On success the command observers fire. [`Error::State`]
/// when no group is open.
pub fn group_end() -> Result<()> {
let mut g = group_lock();
let open = g.take().ok_or(Error::State)?;
let multi = open.multi;
let name = open.name;
drop(g);
// Same NULL-for-empty convention as [`push_or_run`]: the module's
// `read_name` treats NULL like an empty label, while an empty String's
// dangling `as_ptr()` (0x1) would be strlen'd -> SIGSEGV.
let name_ptr = if name.is_empty() {
std::ptr::null()
} else {
name.as_ptr() as *const c_char
};
// push_pre_executed discards an empty multi command. Either way the
// stack took (or destroyed) the command; release our own reference to
// the multi handle.
let rc = undostack_push_pre_executed(stack(), multi, name_ptr);
let mut multi_handle = multi;
command_free(&mut multi_handle);
if rc == 0 {
// The group's children were redo'd eagerly at push time; the whole
// group is one command (commit at group_end).
notify_observers();
Ok(())
} else {
Err(Error::from_code(rc))
}
}
/// Undo all executed children and discard the group. [`Error::State`] when
/// 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)?;
drop(g);
// The multi command itself is never marked done (each child was
// redo'd eagerly at push time), so `undo_now` on it is a no-op. Undo
// the executed children individually instead, in reverse insertion
// order (mirroring the multi's reverse-order undo), each through its
// own borrowed handle.
let mut count: c_int = 0;
let rc = command_multi_child_count(open.multi, &mut count);
if rc != 0 {
let mut multi = open.multi;
command_free(&mut multi);
return Err(Error::from_code(rc));
}
for i in (0..count).rev() {
let mut child = CHandle::null();
let rc = command_multi_child(open.multi, i, &mut child);
if rc != 0 {
let mut multi = open.multi;
command_free(&mut multi);
return Err(Error::from_code(rc));
}
let rc = command_undo_now(child);
// The child handle is borrowed (owns:false): release only its shell
// — the child value lives on in the multi until the multi itself is
// freed below.
command_free(&mut child);
if rc != 0 {
let mut multi = open.multi;
command_free(&mut multi);
return Err(Error::from_code(rc));
}
}
let mut multi = open.multi;
command_free(&mut multi);
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 {
let g = group_lock();
if let Some(group) = g.as_ref() {
// The module's `command_multi_add_child` consumes the child's
// command value (command_take), so the eager redo must happen on the
// still-owned handle FIRST — the group takes the 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;
}
let rc = command_multi_add_child(group.multi, command);
drop(g);
return rc;
}
let stack = stack();
// The module treats a NULL name like an empty label, but an empty Rust
// String's `as_ptr()` is a DANGLING non-NULL pointer (0x1): the module's
// `read_name` would strlen it and SIGSEGV. Pass a real NULL instead.
let label_ptr = if name.is_empty() {
std::ptr::null()
} else {
name.as_ptr() as *const c_char
};
let rc = undostack_push(stack, command, label_ptr);
if rc == 0 {
// The stack took a reference; the command's redo already ran (plan
// M13 D2): persist the write-through subscribers.
notify_observers();
}
rc
}
// ---------------------------------------------------------------------------
// Stack queries and mutations
// ---------------------------------------------------------------------------
/// Total number of history rows.
pub fn count() -> Result<i64> {
let mut c: i64 = 0;
let rc = undostack_count(stack(), &mut c);
if rc == 0 {
Ok(c)
} else {
Err(Error::from_code(rc))
}
}
/// Current position in the history (done-command count).
pub fn index() -> Result<i64> {
let mut i: i64 = 0;
let rc = undostack_index(stack(), &mut i);
if rc == 0 {
Ok(i)
} else {
Err(Error::from_code(rc))
}
}
/// Whether an undo is possible (1/0 via `out_value`; a module error code
/// otherwise).
pub fn can_undo(out_value: *mut c_int) -> Result<()> {
let rc = undostack_can_undo(stack(), out_value);
if rc == 0 {
Ok(())
} else {
Err(Error::from_code(rc))
}
}
/// Whether a redo is possible (1/0 via `out_value`; a module error code
/// otherwise).
pub fn can_redo(out_value: *mut c_int) -> Result<()> {
let rc = undostack_can_redo(stack(), out_value);
if rc == 0 {
Ok(())
} else {
Err(Error::from_code(rc))
}
}
/// Undo/redo until the done-command count equals `index`. On success the
/// bound projects are written through (the jump executed the undo/redo
/// callbacks that mutated them) via the command observers.
pub fn jump(index: i64) -> Result<()> {
let rc = undostack_jump(stack(), index);
if rc == 0 {
notify_observers();
Ok(())
} else {
Err(Error::from_code(rc))
}
}
/// Delete all commands and push the fresh "New/Open Project" empty command.
pub fn clear() -> Result<()> {
let rc = undostack_clear(stack());
if rc == 0 {
Ok(())
} else {
Err(Error::from_code(rc))
}
}
/// 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 fn command_text(row: i64, buf: *mut c_char, buf_size: c_int) -> c_int {
undostack_command_text(stack(), row, buf, buf_size)
}
/// 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 fn command_is_done(row: i64, out_value: *mut c_int) -> Result<()> {
let rc = undostack_command_is_done(stack(), row, out_value);
if rc == 0 {
Ok(())
} else {
Err(Error::from_code(rc))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A no-op observer that counts its invocations.
static COUNT: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
fn counter() {
COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
/// The stack/group/observer state is process-wide: every test here runs
/// serially under this lock, mirroring the facade's GLOBAL_STACK_LOCK
/// pattern.
static LOCK: Mutex<()> = Mutex::new(());
fn vtable_command() -> CHandle {
use crate::undocommand::{command_init, OakUndoCommandVtable};
command_init(
&OakUndoCommandVtable {
redo: None,
undo: None,
free_fn: None,
},
std::ptr::null_mut(),
)
}
#[test]
fn stack_and_group_lifecycle() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
COUNT.store(0, std::sync::atomic::Ordering::SeqCst);
add_observer(counter);
assert!(clear().is_ok());
assert_eq!(count().unwrap(), 1);
assert_eq!(index().unwrap(), 1);
let mut v: c_int = 1;
assert!(can_undo(&mut v).is_ok());
assert_eq!(v, 0);
// Push fires the observer once.
let cmd = vtable_command();
assert_eq!(push_or_run(cmd, "alpha"), 0);
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(c"grouped".as_ptr()).is_ok());
assert!(group_begin(c"again".as_ptr()).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);
// Children joined the group: no observer fire yet.
assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(count().unwrap(), 2);
assert!(group_end().is_ok());
assert_eq!(count().unwrap(), 3);
assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 2);
// Abort fires nothing.
assert!(group_begin(c"abort".as_ptr()).is_ok());
let c3 = vtable_command();
assert_eq!(push_or_run(c3, "c3"), 0);
assert!(group_abort().is_ok());
assert_eq!(count().unwrap(), 3);
assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 2);
// End/abort with no group open: State.
assert!(group_end().is_err());
assert!(group_abort().is_err());
assert!(clear().is_ok());
}
}
+1
View File
@@ -31,6 +31,7 @@
#![warn(missing_docs)]
pub mod error;
pub mod global;
pub mod handle;
pub mod undocommand;
pub mod undostack;