denise_ffi/lib.rs
1//! Denise's C ABI: a `cdylib` for hosts that are not written in Rust.
2//!
3//! Denise's own backends are Rust and need none of this. This crate exists for
4//! the other direction — a Win32 control inside an MFC application, a WinForms or
5//! VB6 host reaching it through the ActiveX shim, an `NSView` in a Cocoa app, a
6//! Python or C# panel on an embedded box. All of them speak C.
7//!
8//! # The shape of it
9//!
10//! The host owns the window and the pixel buffer; Denise owns the widget tree and
11//! draws into whatever it is handed:
12//!
13//! ```c
14//! DeniseUi *ui = denise_ui_new(800, 480, DENISE_THEME_DARK);
15//! uint64_t root = denise_ui_root(ui);
16//! denise_ui_add_button(ui, root, (DeniseRect){20, 20, 160, 44}, "Save", 1, DENISE_ROLE_PRIMARY);
17//!
18//! /* per frame */
19//! denise_ui_tick(ui, now_ms);
20//! if (denise_ui_needs_paint(ui)) {
21//! DeniseFrame frame = { pixels, len, w, h, stride, DENISE_FORMAT_XRGB8888, age };
22//! denise_ui_paint(ui, &frame);
23//! DeniseRect damage[16];
24//! intptr_t n = denise_ui_damage(ui, damage, 16);
25//! /* BitBlt only those rectangles */
26//! denise_ui_presented(ui);
27//! }
28//! uint32_t message;
29//! while (denise_ui_poll_message(ui, &message)) { /* ... */ }
30//! ```
31//!
32//! There is no `Surface` here and no event loop. Both belong to the host, and a
33//! library that tried to own either would be unembeddable in exactly the places
34//! this is for.
35//!
36//! # Rules the whole ABI keeps
37//!
38//! - **Handles are opaque.** A `DeniseUi *` comes from [`denise_ui_new`] and goes
39//! to [`denise_ui_free`]. Nothing else may free it.
40//! - **A node is a `uint64_t`**, and `0` is never a valid node. Ids carry a
41//! generation, so an id kept past a [`denise_ui_remove`] fails to resolve rather
42//! than addressing whoever took the slot.
43//! - **A message is a `uint32_t`**, chosen by the host, and `0` means *no
44//! message*. A button given `0` emits nothing, and [`denise_ui_poll_message`]
45//! never yields it. That is what lets a widget be created without one.
46//! - **Strings are NUL-terminated UTF-8** going in and coming out. Invalid UTF-8
47//! is [`DENISE_ERR_INVALID`], not a replacement character: silently mangling a
48//! host's text is worse than refusing it.
49//! - **A negative return is a status**, and every status has a message from
50//! [`denise_status_message`].
51//! - **Nothing is thread-safe.** One `DeniseUi` belongs to one thread, which for
52//! every host this targets is the UI thread it was created on.
53//!
54//! # Panics do not cross
55//!
56//! Every entry point catches unwinding and returns [`DENISE_ERR_PANIC`]. A panic
57//! is a bug in Denise, and a bug in Denise should not take down a host process
58//! that has unsaved work in three other windows. The call did nothing; the `Ui`
59//! it was called on should be treated as suspect and freed.
60//!
61//! # The header is the contract
62//!
63//! `include/denise.h` is written by hand, not generated. A generated header
64//! follows whatever the Rust happens to say this week, which is the opposite of
65//! what a stable ABI means — the header is the thing that must not move, and the
66//! Rust is what gets checked against it. [`tests/header.rs`](../tests/header.rs)
67//! does the checking: every exported symbol appears in both, with the same
68//! numbers for every key, role and constant.
69
70use std::collections::VecDeque;
71use std::ffi::{CStr, c_char};
72use std::panic::{AssertUnwindSafe, catch_unwind};
73
74use denise_ui::Ui;
75
76pub mod input;
77pub mod keys;
78pub mod paint;
79pub mod tree;
80pub mod types;
81
82// Flattened so a Rust caller of the `rlib` sees the same names in the same shape
83// as the header, rather than having to learn which module a symbol lives in when
84// C never has to. `keys` stays namespaced: 102 constants at the crate root would
85// bury everything else.
86pub use input::*;
87pub use paint::*;
88pub use tree::*;
89pub use types::*;
90
91/// Version of this ABI.
92///
93/// Bumped when a signature, a constant or the meaning of one changes. Added
94/// functions do not bump it. A host that checks nothing else should check this.
95pub const DENISE_ABI_VERSION: u32 = 1;
96
97/// The call succeeded.
98pub const DENISE_OK: i32 = 0;
99/// A required pointer was `NULL`.
100pub const DENISE_ERR_NULL: i32 = -1;
101/// An argument was out of range, or a string was not valid UTF-8.
102pub const DENISE_ERR_INVALID: i32 = -2;
103/// The node id does not name a live node.
104pub const DENISE_ERR_NO_NODE: i32 = -3;
105/// The buffer supplied is too small for the result.
106pub const DENISE_ERR_BUFFER_TOO_SMALL: i32 = -4;
107/// The widget named is not of the kind this call needs.
108pub const DENISE_ERR_WRONG_WIDGET: i32 = -5;
109/// A panic was caught. The call did nothing; treat the `DeniseUi` as suspect.
110pub const DENISE_ERR_PANIC: i32 = -6;
111
112/// The crate version, NUL-terminated and statically allocated.
113///
114/// The trailing NUL is concatenated in rather than written, so this cannot drift
115/// from `Cargo.toml`.
116static VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "\0");
117
118/// Denise's version as a NUL-terminated string. Never `NULL`, never freed.
119#[unsafe(no_mangle)]
120pub extern "C" fn denise_version() -> *const c_char {
121 VERSION.as_ptr().cast()
122}
123
124/// The ABI version this library was built with. See [`DENISE_ABI_VERSION`].
125#[unsafe(no_mangle)]
126pub extern "C" fn denise_abi_version() -> u32 {
127 DENISE_ABI_VERSION
128}
129
130/// A short description of a status code, NUL-terminated. Never `NULL`, never
131/// freed. An unrecognised code gets a generic message rather than `NULL`, because
132/// a host logging an error should not have to handle an error from the logger.
133#[unsafe(no_mangle)]
134pub extern "C" fn denise_status_message(status: i32) -> *const c_char {
135 let text: &CStr = match status {
136 DENISE_OK => c"ok",
137 DENISE_ERR_NULL => c"a required pointer was NULL",
138 DENISE_ERR_INVALID => c"an argument was out of range, or a string was not UTF-8",
139 DENISE_ERR_NO_NODE => c"the node id does not name a live node",
140 DENISE_ERR_BUFFER_TOO_SMALL => c"the buffer supplied is too small",
141 DENISE_ERR_WRONG_WIDGET => c"the node is not a widget of that kind",
142 DENISE_ERR_PANIC => c"denise panicked; the call did nothing",
143 _ => c"unknown status",
144 };
145 text.as_ptr()
146}
147
148/// An opaque user interface. Create with [`denise_ui_new`], destroy with
149/// [`denise_ui_free`].
150///
151/// Messages are `u32` because C has no generics and a host needs to choose its
152/// own vocabulary. They are queued here rather than read from
153/// [`Ui::drain_messages`] on demand so that `0` can be dropped on the way in and
154/// [`denise_ui_poll_message`] can keep its promise never to yield one.
155pub struct DeniseUi {
156 ui: Ui<u32>,
157 pending: VecDeque<u32>,
158}
159
160impl DeniseUi {
161 /// Moves anything the widgets emitted into the queue, discarding the `0`s.
162 fn collect(&mut self) {
163 self.pending
164 .extend(self.ui.drain_messages().filter(|&m| m != 0));
165 }
166}
167
168/// Runs `body`, turning a panic into `fallback`.
169///
170/// Unwinding into C is undefined behaviour. `extern "C"` aborts rather than
171/// unwind, which is sound but takes the host down with it; a status code lets the
172/// host log the bug and carry on with its other windows.
173///
174/// The `AssertUnwindSafe` is deliberate and is the reason [`DENISE_ERR_PANIC`]'s
175/// documentation says to treat the `Ui` as suspect: a panic part-way through a
176/// tree mutation can leave state a later call would observe. Better observable
177/// and recoverable than an aborted process.
178pub(crate) fn guard<T>(fallback: T, body: impl FnOnce() -> T) -> T {
179 match catch_unwind(AssertUnwindSafe(body)) {
180 Ok(value) => value,
181 Err(_) => fallback,
182 }
183}
184
185/// Borrows a handle, or `None` if it is `NULL`.
186///
187/// # Safety
188///
189/// `ui` must be `NULL`, or a pointer from [`denise_ui_new`] that has not been
190/// passed to [`denise_ui_free`].
191pub(crate) unsafe fn handle<'a>(ui: *mut DeniseUi) -> Option<&'a mut DeniseUi> {
192 // SAFETY: the caller promises the pointer is either null or one we handed out
193 // from `Box::into_raw` and still own. `as_mut` handles the null case, and the
194 // ABI's one-thread-per-`DeniseUi` rule is what makes the exclusive borrow
195 // sound.
196 unsafe { ui.as_mut() }
197}
198
199/// Reads a NUL-terminated UTF-8 string, or `None` if it is `NULL` or not UTF-8.
200///
201/// # Safety
202///
203/// `text` must be `NULL`, or point at a NUL-terminated byte string that stays
204/// valid and unwritten for the duration of the call.
205pub(crate) unsafe fn utf8<'a>(text: *const c_char) -> Option<&'a str> {
206 if text.is_null() {
207 return None;
208 }
209 // SAFETY: the caller promises a NUL-terminated string valid for the call, and
210 // the returned lifetime is only ever used within one.
211 unsafe { CStr::from_ptr(text) }.to_str().ok()
212}
213
214/// Creates a user interface `width` by `height` pixels, in one of the built-in
215/// themes ([`DENISE_THEME_DARK`] and friends).
216///
217/// Returns `NULL` if the size is empty or the theme is not one of them.
218#[unsafe(no_mangle)]
219pub extern "C" fn denise_ui_new(width: u32, height: u32, theme: u32) -> *mut DeniseUi {
220 guard(std::ptr::null_mut(), || {
221 let Some(theme) = types::theme(theme) else {
222 return std::ptr::null_mut();
223 };
224 if width == 0 || height == 0 {
225 return std::ptr::null_mut();
226 }
227 Box::into_raw(Box::new(DeniseUi {
228 ui: Ui::new(denise::Size::new(width, height), theme),
229 pending: VecDeque::new(),
230 }))
231 })
232}
233
234/// Creates a user interface with its theme metrics at a scale factor, given in
235/// hundredths — `150` is 1.5x, `200` a 2x display.
236///
237/// This scales the widgets' own furniture: control heights, corner radii,
238/// borders. The rectangles the host passes stay physical pixels, exactly as
239/// before — the host computes them, so the host multiplies them, which is the
240/// same one-place-multiplies rule the Rust API documents. A host reacting to a
241/// DPI change (`WM_DPICHANGED`) rebuilds with the new factor, as it already
242/// rebuilds on a theme change.
243///
244/// Returns `NULL` if the size is empty, the theme unknown, or the scale zero.
245#[unsafe(no_mangle)]
246pub extern "C" fn denise_ui_new_scaled(
247 width: u32,
248 height: u32,
249 theme: u32,
250 scale_x100: u32,
251) -> *mut DeniseUi {
252 guard(std::ptr::null_mut(), || {
253 let Some(theme) = types::theme(theme) else {
254 return std::ptr::null_mut();
255 };
256 if width == 0 || height == 0 || scale_x100 == 0 {
257 return std::ptr::null_mut();
258 }
259 let theme = theme.scaled(scale_x100 as f32 / 100.0);
260 Box::into_raw(Box::new(DeniseUi {
261 ui: Ui::new(denise::Size::new(width, height), theme),
262 pending: VecDeque::new(),
263 }))
264 })
265}
266
267/// Destroys a user interface. `NULL` is accepted and does nothing, as `free`
268/// does.
269///
270/// # Safety
271///
272/// `ui` must be `NULL`, or a pointer from [`denise_ui_new`] not already freed.
273/// Every node id taken from it is dead afterwards.
274#[unsafe(no_mangle)]
275pub unsafe extern "C" fn denise_ui_free(ui: *mut DeniseUi) {
276 if ui.is_null() {
277 return;
278 }
279 guard((), || {
280 // SAFETY: the caller promises this came from `denise_ui_new`, which built
281 // it with `Box::into_raw`, and that it has not been freed already.
282 drop(unsafe { Box::from_raw(ui) });
283 });
284}
285
286/// Switches to one of the built-in themes and repaints everything.
287///
288/// # Safety
289///
290/// `ui` must be a live handle from [`denise_ui_new`].
291#[unsafe(no_mangle)]
292pub unsafe extern "C" fn denise_ui_set_theme(ui: *mut DeniseUi, theme: u32) -> i32 {
293 guard(DENISE_ERR_PANIC, || {
294 // SAFETY: forwarding the caller's promise about `ui`.
295 let Some(handle) = (unsafe { handle(ui) }) else {
296 return DENISE_ERR_NULL;
297 };
298 let Some(theme) = types::theme(theme) else {
299 return DENISE_ERR_INVALID;
300 };
301 handle.ui.set_theme(theme);
302 DENISE_OK
303 })
304}
305
306/// Writes the surface size. Either output may be `NULL`.
307///
308/// # Safety
309///
310/// `ui` must be a live handle; `width` and `height` must be `NULL` or point at
311/// writable `uint32_t`s.
312#[unsafe(no_mangle)]
313pub unsafe extern "C" fn denise_ui_size(
314 ui: *mut DeniseUi,
315 width: *mut u32,
316 height: *mut u32,
317) -> i32 {
318 guard(DENISE_ERR_PANIC, || {
319 // SAFETY: forwarding the caller's promise about `ui`.
320 let Some(handle) = (unsafe { handle(ui) }) else {
321 return DENISE_ERR_NULL;
322 };
323 let size = handle.ui.size();
324 if !width.is_null() {
325 // SAFETY: the caller promises a writable `uint32_t` when not null.
326 unsafe { width.write(size.width) };
327 }
328 if !height.is_null() {
329 // SAFETY: as above.
330 unsafe { height.write(size.height) };
331 }
332 DENISE_OK
333 })
334}
335
336/// Takes the next message, or returns `false` if there is none.
337///
338/// Never yields `0`; see the crate documentation for why.
339///
340/// # Safety
341///
342/// `ui` must be a live handle; `out` must be `NULL` or point at a writable
343/// `uint32_t`.
344#[unsafe(no_mangle)]
345pub unsafe extern "C" fn denise_ui_poll_message(ui: *mut DeniseUi, out: *mut u32) -> bool {
346 guard(false, || {
347 // SAFETY: forwarding the caller's promise about `ui`.
348 let Some(handle) = (unsafe { handle(ui) }) else {
349 return false;
350 };
351 handle.collect();
352 let Some(message) = handle.pending.pop_front() else {
353 return false;
354 };
355 if !out.is_null() {
356 // SAFETY: the caller promises a writable `uint32_t` when not null.
357 unsafe { out.write(message) };
358 }
359 true
360 })
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn the_version_string_is_nul_terminated_and_matches_the_crate() {
369 assert!(VERSION.ends_with('\0'));
370 // SAFETY: `VERSION` is a static `&str` with exactly one NUL, at the end.
371 let text = unsafe { CStr::from_ptr(denise_version()) };
372 assert_eq!(text.to_str().unwrap(), env!("CARGO_PKG_VERSION"));
373 }
374
375 #[test]
376 fn every_status_has_its_own_message() {
377 let codes = [
378 DENISE_OK,
379 DENISE_ERR_NULL,
380 DENISE_ERR_INVALID,
381 DENISE_ERR_NO_NODE,
382 DENISE_ERR_BUFFER_TOO_SMALL,
383 DENISE_ERR_WRONG_WIDGET,
384 DENISE_ERR_PANIC,
385 ];
386 let mut seen: Vec<&str> = Vec::new();
387 for code in codes {
388 // SAFETY: `denise_status_message` returns a static C string.
389 let text = unsafe { CStr::from_ptr(denise_status_message(code)) }
390 .to_str()
391 .unwrap();
392 assert!(
393 !seen.contains(&text),
394 "{code} shares a message with another"
395 );
396 seen.push(text);
397 }
398 // SAFETY: as above.
399 let unknown = unsafe { CStr::from_ptr(denise_status_message(-999)) };
400 assert_eq!(unknown.to_str().unwrap(), "unknown status");
401 }
402
403 /// Every entry point must survive a `NULL` handle, because in a host that
404 /// reaches Denise through three layers of marshalling it will get one.
405 #[test]
406 fn a_null_handle_is_refused_rather_than_dereferenced() {
407 // SAFETY: passing null is exactly what the ABI documents as accepted.
408 unsafe {
409 denise_ui_free(std::ptr::null_mut());
410 assert_eq!(
411 denise_ui_set_theme(std::ptr::null_mut(), 0),
412 DENISE_ERR_NULL
413 );
414 assert_eq!(
415 denise_ui_size(
416 std::ptr::null_mut(),
417 std::ptr::null_mut(),
418 std::ptr::null_mut()
419 ),
420 DENISE_ERR_NULL
421 );
422 assert!(!denise_ui_poll_message(
423 std::ptr::null_mut(),
424 std::ptr::null_mut()
425 ));
426 }
427 }
428
429 #[test]
430 fn a_bad_size_or_theme_produces_no_handle() {
431 assert!(denise_ui_new(0, 480, DENISE_THEME_DARK).is_null());
432 assert!(denise_ui_new(800, 0, DENISE_THEME_DARK).is_null());
433 assert!(denise_ui_new(800, 480, 99).is_null());
434
435 let ui = denise_ui_new(800, 480, DENISE_THEME_DARK);
436 assert!(!ui.is_null());
437 // SAFETY: `ui` came from `denise_ui_new` and is freed exactly once.
438 unsafe { denise_ui_free(ui) };
439 }
440}
441
442/// Compiles the examples in this crate's README, so they cannot drift from the API
443/// they claim to demonstrate. Never built except under `cargo test --doc`.
444#[cfg(doctest)]
445#[doc = include_str!("../README.md")]
446struct Readme;