cad-editor/crates/ocs_plugin_api/src/ribbon.rs

319 lines
12 KiB
Rust
Raw Normal View History

//! Ribbon description types — the plain-data vocabulary a [`CadModule`] uses to
//! declare its tab. No UI-framework dependency: the host renders these.
2026-06-23 22:54:33 +02:00
#[cfg(feature = "host")]
pub mod owned;
// ── Events ────────────────────────────────────────────────────────────────
/// Events a module tool can emit to the host application.
#[derive(Debug, Clone)]
2026-06-23 22:54:33 +02:00
#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))]
pub enum ModuleEvent {
/// Fire a named CAD command (e.g. "LINE", "CIRCLE").
Command(String),
/// Open the OS file dialog.
OpenFileDialog,
/// Remove all loaded models from the scene.
#[allow(dead_code)]
ClearModels,
/// Toggle the layer manager panel.
ToggleLayers,
/// Ask the host to open a native file picker. On selection the host
/// dispatches `"<command> <path>"` back to the plugin (full original case,
/// bypassing the command line so case-sensitive paths/args survive); on
/// cancel nothing happens. Lets an add-on import files without owning any
/// dialog UI.
PluginFileDialog {
/// Plugin command to dispatch with the chosen path appended.
command: String,
/// Dialog window title.
title: String,
/// Human label for the file-type filter (e.g. "PNEZD Points").
filter_name: String,
/// Accepted extensions, without the dot (e.g. `["csv", "txt"]`).
extensions: Vec<String>,
},
}
// ── Data types ────────────────────────────────────────────────────────────
/// Icon source for a ribbon tool button.
#[derive(Clone, Copy)]
pub enum IconKind {
/// Unicode glyph rendered as text (fast, no file needed).
Glyph(&'static str),
/// Raw SVG bytes embedded at compile time via `include_bytes!`.
Svg(&'static [u8]),
}
/// A single tool button shown in the ribbon.
#[derive(Clone)]
pub struct ToolDef {
/// Unique command id, e.g. "LINE".
pub id: &'static str,
/// Short label shown under the icon.
pub label: &'static str,
/// Icon — either a unicode glyph or embedded SVG bytes.
pub icon: IconKind,
/// Event emitted when the tool is clicked.
pub event: ModuleEvent,
}
/// One item in a ribbon group — plain button or dropdown, in small (1-row) or large (3-row) size.
#[derive(Clone)]
pub enum RibbonItem {
/// 1-row button — icon only, no label.
Tool(ToolDef),
/// 3-row button — icon + label below; full ribbon height.
LargeTool(ToolDef),
/// 1-row dropdown — icon + ▾ on right, no label.
Dropdown {
id: &'static str,
icon: IconKind,
items: Vec<(&'static str, &'static str, IconKind)>,
default: &'static str,
},
/// 3-row dropdown — icon + label + ▾ below label; full ribbon height.
LargeDropdown {
id: &'static str,
label: &'static str,
icon: IconKind,
items: Vec<(&'static str, &'static str, IconKind)>,
default: &'static str,
},
/// Layer combo + two rows of small tools below.
/// row2: operates on the layer of a selected object (off/freeze/lock/make-current)
/// row3: all-layers operations + match (on/thaw/unlock/match)
LayerComboGroup {
row2: Vec<ToolDef>,
row3: Vec<ToolDef>,
},
/// Match Properties (large button) + Color / Linetype / Lineweight combos on the right.
PropertiesGroup { match_prop: ToolDef },
/// A style selector combobox (text / dim / mleader / table style) with
/// optional small tool rows below it.
StyleComboGroup {
/// Which style domain this combo controls.
style_key: StyleKey,
/// Unique dropdown id (must be unique across the ribbon).
combo_id: &'static str,
/// Optional command to run when the user opens the style manager.
manager_cmd: Option<&'static str>,
/// Small tool rows rendered below the combo (0–2 rows).
rows: Vec<Vec<ToolDef>>,
},
}
/// Identifies which style list a `StyleComboGroup` refers to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2026-06-23 22:54:33 +02:00
#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))]
pub enum StyleKey {
TextStyle,
DimStyle,
MLeaderStyle,
TableStyle,
}
impl From<ToolDef> for RibbonItem {
fn from(t: ToolDef) -> Self {
RibbonItem::Tool(t)
}
}
/// A named group of tool buttons shown together in the ribbon.
2026-06-23 22:54:33 +02:00
#[derive(Clone)]
pub struct RibbonGroup {
pub title: &'static str,
pub tools: Vec<RibbonItem>,
}
// ── Trait ─────────────────────────────────────────────────────────────────
/// A CAD module owns a set of ribbon groups shown when its tab is active.
/// Each module is a stateless unit struct — all UI state lives in Ribbon.
pub trait CadModule: Send + Sync {
#[allow(dead_code)]
fn id(&self) -> &'static str;
fn title(&self) -> &'static str;
fn ribbon_groups(&self) -> &[RibbonGroup];
}
#[cfg(test)]
mod tests {
use super::*;
// Timing microbenchmark, not a correctness check: it intentionally
// `Box::leak`s ~400k strings to defeat the allocator's reuse, so keep it out
// of the default suite. Run manually with `cargo test -- --ignored`.
// Correctness of the cache is covered by
// `once_lock_produces_identical_pointer_on_subsequent_calls`.
#[test]
#[ignore = "microbenchmark: leaks strings by design; run with --ignored"]
fn once_lock_eliminates_allocation_after_first_call() {
// Helper to build a realistic module tree (2 groups, ~20 items)
// Benchmark builds this manually for a clean before/after comparison.
fn build_tree() -> Vec<RibbonGroup> {
vec![
RibbonGroup {
title: "Draw",
tools: (0..10)
.map(|i| {
RibbonItem::Tool(ToolDef {
id: &*Box::leak(format!("TOOL_{i}").into_boxed_str()),
label: &*Box::leak(format!("Tool {i}").into_boxed_str()),
icon: IconKind::Glyph("T"),
event: ModuleEvent::Command(format!("CMD_{i}")),
})
})
.collect(),
},
RibbonGroup {
title: "Modify",
tools: (0..10)
.map(|i| {
RibbonItem::Tool(ToolDef {
id: &*Box::leak(format!("MOD_{i}").into_boxed_str()),
label: &*Box::leak(format!("Mod {i}").into_boxed_str()),
icon: IconKind::Glyph("M"),
event: ModuleEvent::Command(format!("MODIFY_{i}")),
})
})
.collect(),
},
]
}
// ── Before: rebuild the tree every call (what every frame used to pay) ──
const N: usize = 10_000;
let start = std::time::Instant::now();
let mut total_len = 0usize;
for _ in 0..N {
let groups = std::hint::black_box(build_tree());
total_len += std::hint::black_box(groups.len());
// Prevent optimizer from reusing the allocation across iterations
// by leaking the Vec — otherwise LLVM coalesces the Vec into a
// single allocation for the whole loop, which under-counts the
// real per-frame cost. black_box on .as_ptr() forces the box to
// be materialised even when nothing else consumes it.
std::hint::black_box(groups.as_ptr());
}
let before_elapsed = start.elapsed();
eprintln!(
"BEFORE (rebuild each call): {N} builds in {before_elapsed:?} \
({:.1} µs/build, total_len={total_len})",
before_elapsed.as_secs_f64() * 1_000_000.0 / N as f64,
);
// ── After: cached via OnceLock ──
struct Cached;
impl CadModule for Cached {
fn id(&self) -> &'static str {
"cached"
}
fn title(&self) -> &'static str {
"Cached"
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(build_tree)
}
}
let m = Cached;
let _ = m.ribbon_groups(); // warm-up — pays construction cost once
let start = std::time::Instant::now();
let mut total_len = 0usize;
for _ in 0..N {
let groups = std::hint::black_box(m.ribbon_groups());
total_len += std::hint::black_box(groups.len());
}
let after_elapsed = start.elapsed();
eprintln!(
"AFTER (cached): {N} calls in {after_elapsed:?} \
({:.1} ns/call, total_len={total_len})",
after_elapsed.as_secs_f64() * 1_000_000_000.0 / N as f64,
);
// Ratio: serialize cost in ns/call to avoid division by zero
let before_ns = before_elapsed.as_nanos() as f64 / N as f64;
let after_ns = after_elapsed.as_nanos() as f64 / N as f64;
let ratio = if after_ns > 0.0 {
before_ns / after_ns
} else {
f64::INFINITY
};
eprintln!(
"BEFORE vs AFTER: {before_ns:.0} ns/call vs {after_ns:.1} ns/call ({ratio:.0}× faster)"
);
assert_eq!(total_len, 2 * N, "each call must return 2 groups");
}
#[test]
fn tool_def_converts_into_small_tool() {
let tool = ToolDef {
id: "LINE",
label: "Line",
icon: IconKind::Glyph("/"),
event: ModuleEvent::Command("LINE".to_string()),
};
assert!(matches!(RibbonItem::from(tool), RibbonItem::Tool(_)));
}
#[test]
fn once_lock_produces_identical_pointer_on_subsequent_calls() {
struct Demo;
impl CadModule for Demo {
fn id(&self) -> &'static str {
"demo"
}
fn title(&self) -> &'static str {
"Demo"
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| {
vec![RibbonGroup {
title: "Group",
tools: vec![RibbonItem::Tool(ToolDef {
id: "LINE",
label: "Line",
icon: IconKind::Glyph("/"),
event: ModuleEvent::Command("LINE".to_string()),
})],
}]
})
}
}
let m = Demo;
let first: *const [RibbonGroup] = m.ribbon_groups();
let second: *const [RibbonGroup] = m.ribbon_groups();
assert_eq!(first, second, "cached calls must return the same pointer");
}
#[test]
fn cad_module_is_object_safe() {
struct Demo;
impl CadModule for Demo {
fn id(&self) -> &'static str {
"demo"
}
fn title(&self) -> &'static str {
"Demo"
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| {
vec![RibbonGroup {
title: "Group",
tools: vec![],
}]
})
}
}
let m: Box<dyn CadModule> = Box::new(Demo);
assert_eq!(m.title(), "Demo");
assert_eq!(m.ribbon_groups().len(), 1);
}
}