ratatui_unity/lib.rs
1//! # ratatui_unity
2//!
3//! A C ABI wrapper around [`ratatui`] that renders terminal UIs to RGB24
4//! pixel buffers, suitable for embedding in game engines (e.g. Unity).
5//!
6//! The crate is compiled as both `cdylib` and `staticlib` so that it can be
7//! consumed from any host capable of calling C functions. All public entry
8//! points are `extern "C"` and `#[no_mangle]`; there is no idiomatic Rust API.
9//!
10//! ## High-level flow
11//!
12//! 1. Create a terminal handle with [`ratatui_create`].
13//! 2. For each frame:
14//! - Call [`ratatui_begin_frame`] to reset per-frame state.
15//! - Build a layout tree with [`ratatui_split`] / [`ratatui_inner`].
16//! - Optionally set a style with [`ratatui_set_style`] before any widget call.
17//! - Queue widget commands (e.g. [`ratatui_block`], [`ratatui_paragraph`],
18//! [`ratatui_chart_begin`] / [`ratatui_chart_end`], …).
19//! - Call [`ratatui_end_frame`] (or [`ratatui_end_frame_hashed`]) to draw
20//! the queue and rasterize the cell grid into an RGB24 pixel buffer.
21//! 3. When done, call [`ratatui_destroy`] to release the handle.
22//!
23//! ## Memory & lifetime
24//!
25//! The handle returned by [`ratatui_create`] is an opaque pointer to a
26//! heap-allocated `TerminalState`. The pixel
27//! buffer pointer returned by `ratatui_end_frame*` is owned by the handle and
28//! is only valid until the next FFI call that mutates the handle. The caller
29//! must copy the bytes before issuing further calls if it wants to retain them.
30//!
31//! ## Safety
32//!
33//! All FFI entry points perform null-pointer checks on the handle and on any
34//! pointer argument they dereference. Strings are read as null-terminated
35//! `*const c_char`. Slices passed as `(ptr, len)` pairs must reference valid
36//! memory for the duration of the call.
37
38mod color;
39mod commands;
40mod font;
41mod renderer;
42mod terminal;
43
44use crate::commands::{do_split, render_all_commands};
45use crate::terminal::{
46 AxisInfo, CanvasShape, DatasetInfo, PendingCanvas, PendingChart, PendingStyledParagraph,
47 SpanInfo, TerminalState, WidgetCommand,
48};
49use ratatui::style::{Color, Modifier, Style};
50use std::ffi::{c_void, CStr};
51use std::os::raw::c_char;
52
53// ─── Helpers ─────────────────────────────────────────────────────────────────
54
55/// Reinterprets an opaque handle as a mutable reference to [`TerminalState`].
56///
57/// # Safety
58///
59/// `handle` must be a non-null pointer previously returned by
60/// [`ratatui_create`] and not yet passed to [`ratatui_destroy`]. The borrow's
61/// lifetime is unbounded; callers must ensure no other reference to the same
62/// state is active for the duration of the returned borrow.
63unsafe fn state_mut<'a>(handle: *mut c_void) -> &'a mut TerminalState {
64 &mut *(handle as *mut TerminalState)
65}
66
67/// Copies a nullable null-terminated C string into an owned [`String`].
68///
69/// Returns an empty `String` when `ptr` is null. Invalid UTF-8 is replaced
70/// using [`String::from_utf8_lossy`].
71///
72/// # Safety
73///
74/// `ptr`, if non-null, must point to a valid null-terminated byte sequence.
75unsafe fn cstr_to_string(ptr: *const c_char) -> String {
76 if ptr.is_null() {
77 return String::new();
78 }
79 CStr::from_ptr(ptr).to_string_lossy().into_owned()
80}
81
82/// Builds a ratatui [`Style`] from the packed FFI representation.
83///
84/// `use_default_fg` / `use_default_bg`: non-zero means "leave foreground /
85/// background unset so the terminal default is used"; zero means apply the
86/// given RGB triple.
87///
88/// `modifiers` is a bit field:
89/// - `0x01` Bold
90/// - `0x02` Italic
91/// - `0x04` Underlined
92/// - `0x08` Dim
93fn style_from_rgba(
94 fg_r: u8, fg_g: u8, fg_b: u8, use_default_fg: u8,
95 bg_r: u8, bg_g: u8, bg_b: u8, use_default_bg: u8,
96 modifiers: u8,
97) -> Style {
98 let mut style = Style::default();
99 if use_default_fg == 0 { style = style.fg(Color::Rgb(fg_r, fg_g, fg_b)); }
100 if use_default_bg == 0 { style = style.bg(Color::Rgb(bg_r, bg_g, bg_b)); }
101 let mut modifier = Modifier::empty();
102 if modifiers & 0x01 != 0 { modifier |= Modifier::BOLD; }
103 if modifiers & 0x02 != 0 { modifier |= Modifier::ITALIC; }
104 if modifiers & 0x04 != 0 { modifier |= Modifier::UNDERLINED; }
105 if modifiers & 0x08 != 0 { modifier |= Modifier::DIM; }
106 if !modifier.is_empty() { style = style.add_modifier(modifier); }
107 style
108}
109
110// ─── Background color ─────────────────────────────────────────────────────────
111
112/// Sets the RGB background color used by the rasterizer for cells whose
113/// background is [`Color::Reset`].
114///
115/// The value persists across frames until changed again. Setting this between
116/// frames is supported; setting it mid-frame only affects subsequent calls
117/// to `ratatui_end_frame*`.
118#[no_mangle]
119pub extern "C" fn ratatui_set_background_color(
120 handle: *mut c_void,
121 r: u8, g: u8, b: u8,
122) {
123 if handle.is_null() { return; }
124 let state = unsafe { state_mut(handle) };
125 state.background_color = [r, g, b];
126}
127
128// ─── Lifecycle ───────────────────────────────────────────────────────────────
129
130/// Creates a terminal instance and returns an opaque handle.
131///
132/// The resulting handle owns:
133/// - a ratatui [`Terminal`](ratatui::Terminal) backed by [`TestBackend`](ratatui::backend::TestBackend)
134/// sized `cols × rows` cells,
135/// - a glyph-cached `FontManager` at `font_size` pixels,
136/// - a pre-allocated RGB24 pixel buffer matching `cols × rows × cell_size`.
137///
138/// The handle must eventually be released with [`ratatui_destroy`].
139///
140/// # Parameters
141/// - `cols`: terminal width in character cells.
142/// - `rows`: terminal height in character cells.
143/// - `font_size`: glyph rasterization size in pixels (e.g. `14.0`).
144#[no_mangle]
145pub extern "C" fn ratatui_create(cols: u16, rows: u16, font_size: f32) -> *mut c_void {
146 let state = Box::new(TerminalState::new(cols, rows, font_size));
147 Box::into_raw(state) as *mut c_void
148}
149
150/// Destroys a terminal handle created by [`ratatui_create`].
151///
152/// After this call the handle and any previously returned pixel-buffer
153/// pointers are invalid and must not be used. A null handle is a no-op.
154#[no_mangle]
155pub extern "C" fn ratatui_destroy(handle: *mut c_void) {
156 if !handle.is_null() {
157 unsafe { drop(Box::from_raw(handle as *mut TerminalState)); }
158 }
159}
160
161/// Replaces the embedded JetBrains Mono font with custom TTF bytes.
162///
163/// The cell width/height are recomputed from the new font's metrics, the
164/// glyph cache is dropped, and the pixel buffer is resized to the new
165/// `cols × cell_size` dimensions. After a successful call the values
166/// reported by [`ratatui_pixel_width`] / [`ratatui_pixel_height`] change
167/// accordingly; callers must re-query them and resize any host-side
168/// texture before the next `ratatui_end_frame*` call.
169///
170/// # Returns
171/// `1` on success, `0` if `handle` is null, `font_data` is null/empty, or the
172/// bytes are not a valid font.
173#[no_mangle]
174pub extern "C" fn ratatui_set_custom_font(
175 handle: *mut c_void,
176 font_data: *const u8,
177 font_len: u32,
178) -> u8 {
179 if handle.is_null() || font_data.is_null() || font_len == 0 { return 0; }
180 let state = unsafe { state_mut(handle) };
181 let bytes = unsafe { std::slice::from_raw_parts(font_data, font_len as usize) };
182 let ok = state.font.set_custom_font(bytes);
183 if ok {
184 state.resync_pixel_dimensions();
185 }
186 u8::from(ok)
187}
188
189// ─── Frame ───────────────────────────────────────────────────────────────────
190
191/// Begins a new frame.
192///
193/// Clears the queued widget command list, drops any in-progress builder state
194/// (styled paragraph, chart, canvas), resets the pending style to default, and
195/// resets the area map so that only the root area (id `0`) remains. Must be
196/// called before issuing widget commands for the new frame.
197#[no_mangle]
198pub extern "C" fn ratatui_begin_frame(handle: *mut c_void) {
199 if handle.is_null() { return; }
200 unsafe { state_mut(handle) }.begin_frame();
201}
202
203/// Renders all queued widget commands and rasterizes the cell buffer.
204///
205/// The returned pointer addresses a flat RGB24 buffer of size
206/// `pixel_width * pixel_height * 3` bytes, owned by the handle. The pointer is
207/// valid until the next FFI call that mutates the handle (typically the next
208/// `ratatui_end_frame*` call), at which point the buffer may be overwritten.
209///
210/// Returns `null` only when `handle` is null.
211#[no_mangle]
212pub extern "C" fn ratatui_end_frame(handle: *mut c_void) -> *const u8 {
213 if handle.is_null() { return std::ptr::null(); }
214 let state = unsafe { state_mut(handle) };
215 render_all_commands(state);
216 state.rasterize();
217 state.pixel_buffer.as_ptr()
218}
219
220/// Like `ratatui_end_frame`, but skips rasterization when the cell buffer
221/// is unchanged from the previous frame (hash-based dirty check).
222/// Returns a valid pixel pointer when content changed, or null when unchanged.
223/// The previous frame's pixel buffer remains valid when null is returned.
224#[no_mangle]
225pub extern "C" fn ratatui_end_frame_hashed(handle: *mut c_void) -> *const u8 {
226 if handle.is_null() { return std::ptr::null(); }
227 let state = unsafe { state_mut(handle) };
228 render_all_commands(state);
229
230 let hash = {
231 let buffer = state.terminal.backend().buffer();
232 crate::renderer::compute_buffer_hash(buffer)
233 };
234
235 if state.last_buffer_hash == Some(hash) {
236 return std::ptr::null();
237 }
238
239 state.last_buffer_hash = Some(hash);
240 state.rasterize();
241 state.pixel_buffer.as_ptr()
242}
243
244/// Returns the width of the pixel buffer in pixels (`cols * cell_width`).
245///
246/// Returns `0` if `handle` is null.
247#[no_mangle]
248pub extern "C" fn ratatui_pixel_width(handle: *const c_void) -> u32 {
249 if handle.is_null() { return 0; }
250 unsafe { &*(handle as *const TerminalState) }.pixel_width
251}
252
253/// Returns the height of the pixel buffer in pixels (`rows * cell_height`).
254///
255/// Returns `0` if `handle` is null.
256#[no_mangle]
257pub extern "C" fn ratatui_pixel_height(handle: *const c_void) -> u32 {
258 if handle.is_null() { return 0; }
259 unsafe { &*(handle as *const TerminalState) }.pixel_height
260}
261
262// ─── Layout ──────────────────────────────────────────────────────────────────
263
264/// Returns the id of the root area, which always covers the whole terminal.
265///
266/// The root id is the constant `0`; this getter exists for symmetry with the
267/// host-side area API.
268#[no_mangle]
269pub extern "C" fn ratatui_root_area(_handle: *const c_void) -> u32 { 0 }
270
271/// Splits an existing area into `count` child areas and writes their ids into
272/// `out_ids`.
273///
274/// # Parameters
275/// - `area_id`: id of the parent area to split.
276/// - `direction`: `0` = horizontal split (left → right), any other value =
277/// vertical split (top → bottom).
278/// - `constraint_types`: array of length `count` describing each child's
279/// constraint kind. Values: `0` = Length, `1` = Min, `2` = Max,
280/// `3` = Percentage, `4` (or any other) = Fill.
281/// - `constraint_values`: array of length `count` with the numeric value
282/// matching the constraint kind (cells for Length/Min/Max, 0..=100 for
283/// Percentage, weight for Fill).
284/// - `count`: number of child areas requested.
285/// - `out_ids`: caller-allocated buffer of length `count` that receives the
286/// ids of the newly registered child areas.
287///
288/// # Returns
289/// The number of child areas actually written. Returns `0` if any required
290/// pointer is null, `count` is zero, or the parent id is unknown.
291#[no_mangle]
292pub extern "C" fn ratatui_split(
293 handle: *mut c_void,
294 area_id: u32,
295 direction: u8,
296 constraint_types: *const u8,
297 constraint_values: *const u16,
298 count: u32,
299 out_ids: *mut u32,
300) -> u32 {
301 if handle.is_null()
302 || constraint_types.is_null()
303 || constraint_values.is_null()
304 || out_ids.is_null()
305 || count == 0
306 {
307 return 0;
308 }
309 let state = unsafe { state_mut(handle) };
310 let types = unsafe { std::slice::from_raw_parts(constraint_types, count as usize) };
311 let values = unsafe { std::slice::from_raw_parts(constraint_values, count as usize) };
312 let out = unsafe { std::slice::from_raw_parts_mut(out_ids, count as usize) };
313 do_split(state, area_id, direction, types, values, out)
314}
315
316/// Returns a new area id covering the inner rectangle of `area_id` shrunk by
317/// the given margins on each side.
318///
319/// # Parameters
320/// - `area_id`: parent area id.
321/// - `horizontal`: cells to remove from the left and right edges.
322/// - `vertical`: cells to remove from the top and bottom edges.
323///
324/// # Returns
325/// The id of the newly registered inner area, or [`u32::MAX`] if `handle` is
326/// null or `area_id` is unknown.
327#[no_mangle]
328pub extern "C" fn ratatui_inner(
329 handle: *mut c_void,
330 area_id: u32,
331 horizontal: u16,
332 vertical: u16,
333) -> u32 {
334 if handle.is_null() { return u32::MAX; }
335 let state = unsafe { state_mut(handle) };
336 let area = match state.area_map.get(&area_id).copied() {
337 Some(r) => r,
338 None => return u32::MAX,
339 };
340 use ratatui::layout::Margin;
341 let inner = area.inner(Margin { horizontal, vertical });
342 state.register_area(inner)
343}
344
345// ─── Style ───────────────────────────────────────────────────────────────────
346
347/// Sets the pending style consumed by the next widget-producing FFI call.
348///
349/// The pending style is reset to default after each widget call and at the
350/// start of every frame. Widgets that do not accept a style (e.g. scrollbar,
351/// calendar, chart, canvas) ignore the pending style.
352///
353/// # Parameters
354/// - `fg_r`, `fg_g`, `fg_b`: foreground RGB components.
355/// - `use_default_fg`: non-zero to leave the foreground unset (terminal
356/// default); zero to apply the given RGB triple.
357/// - `bg_r`, `bg_g`, `bg_b`: background RGB components.
358/// - `use_default_bg`: non-zero to leave the background unset; zero to apply
359/// the given RGB triple.
360/// - `modifiers`: bit field — `0x01` Bold, `0x02` Italic, `0x04` Underlined,
361/// `0x08` Dim.
362#[no_mangle]
363pub extern "C" fn ratatui_set_style(
364 handle: *mut c_void,
365 fg_r: u8, fg_g: u8, fg_b: u8, use_default_fg: u8,
366 bg_r: u8, bg_g: u8, bg_b: u8, use_default_bg: u8,
367 modifiers: u8,
368) {
369 if handle.is_null() { return; }
370 let state = unsafe { state_mut(handle) };
371 state.pending_style = style_from_rgba(
372 fg_r, fg_g, fg_b, use_default_fg,
373 bg_r, bg_g, bg_b, use_default_bg,
374 modifiers,
375 );
376}
377
378// ─── Basic widgets ────────────────────────────────────────────────────────────
379
380/// Queues a [`Block`](ratatui::widgets::Block) widget with an optional title
381/// and per-edge borders.
382///
383/// `borders` is a bit field — `0x01` Top, `0x02` Bottom, `0x04` Left,
384/// `0x08` Right. The value `0x0F` is treated as "all borders".
385///
386/// The pending style (see [`ratatui_set_style`]) is consumed and applied to
387/// the block.
388#[no_mangle]
389pub extern "C" fn ratatui_block(
390 handle: *mut c_void,
391 area_id: u32,
392 title: *const c_char,
393 borders: u8,
394) {
395 if handle.is_null() { return; }
396 let state = unsafe { state_mut(handle) };
397 let style = state.take_style();
398 state.commands.push(WidgetCommand::Block {
399 area_id,
400 title: unsafe { cstr_to_string(title) },
401 borders,
402 style,
403 });
404}
405
406/// Queues a uniformly styled [`Paragraph`](ratatui::widgets::Paragraph).
407///
408/// # Parameters
409/// - `text`: paragraph contents. Embedded `\n` produces line breaks.
410/// - `alignment`: `0` Left, `1` Center, `2` Right.
411/// - `wrap`: non-zero to enable word wrapping (`trim: false`).
412///
413/// For multi-style text use the styled-paragraph builder
414/// ([`ratatui_styled_para_begin`] / [`ratatui_styled_para_span`] /
415/// [`ratatui_styled_para_newline`] / [`ratatui_styled_para_end`]).
416#[no_mangle]
417pub extern "C" fn ratatui_paragraph(
418 handle: *mut c_void,
419 area_id: u32,
420 text: *const c_char,
421 alignment: u8,
422 wrap: u8,
423) {
424 if handle.is_null() { return; }
425 let state = unsafe { state_mut(handle) };
426 let style = state.take_style();
427 state.commands.push(WidgetCommand::Paragraph {
428 area_id,
429 text: unsafe { cstr_to_string(text) },
430 alignment,
431 wrap: wrap != 0,
432 style,
433 });
434}
435
436/// Queues a [`List`](ratatui::widgets::List) widget.
437///
438/// # Parameters
439/// - `items`: newline-separated list entries.
440/// - `selected`: zero-based index of the highlighted row, or `-1` for no
441/// selection. The highlight uses `"> "` as the prefix and a bold modifier.
442#[no_mangle]
443pub extern "C" fn ratatui_list(
444 handle: *mut c_void,
445 area_id: u32,
446 items: *const c_char,
447 selected: i32,
448) {
449 if handle.is_null() { return; }
450 let state = unsafe { state_mut(handle) };
451 let style = state.take_style();
452 state.commands.push(WidgetCommand::List {
453 area_id,
454 items: unsafe { cstr_to_string(items) },
455 selected,
456 style,
457 });
458}
459
460/// Queues a block-style [`Gauge`](ratatui::widgets::Gauge).
461///
462/// # Parameters
463/// - `ratio`: progress in `[0.0, 1.0]`. Values outside the range are clamped.
464/// - `label`: optional text overlaid on the gauge (pass `null` or empty for none).
465#[no_mangle]
466pub extern "C" fn ratatui_gauge(
467 handle: *mut c_void,
468 area_id: u32,
469 ratio: f32,
470 label: *const c_char,
471) {
472 if handle.is_null() { return; }
473 let state = unsafe { state_mut(handle) };
474 let style = state.take_style();
475 state.commands.push(WidgetCommand::Gauge {
476 area_id,
477 ratio: ratio as f64,
478 label: unsafe { cstr_to_string(label) },
479 style,
480 });
481}
482
483/// Queues a [`Tabs`](ratatui::widgets::Tabs) bar.
484///
485/// # Parameters
486/// - `titles`: newline-separated tab labels.
487/// - `selected`: zero-based index of the active tab.
488///
489/// The pending style's foreground color (or cyan if unset) is used as the
490/// highlight background of the active tab.
491#[no_mangle]
492pub extern "C" fn ratatui_tabs(
493 handle: *mut c_void,
494 area_id: u32,
495 titles: *const c_char,
496 selected: u32,
497) {
498 if handle.is_null() { return; }
499 let state = unsafe { state_mut(handle) };
500 let style = state.take_style();
501 state.commands.push(WidgetCommand::Tabs {
502 area_id,
503 titles: unsafe { cstr_to_string(titles) },
504 selected,
505 style,
506 });
507}
508
509/// Queues a [`Sparkline`](ratatui::widgets::Sparkline) from raw `u64` samples.
510///
511/// # Parameters
512/// - `data`: pointer to `len` `u64` samples.
513/// - `len`: number of samples.
514#[no_mangle]
515pub extern "C" fn ratatui_sparkline(
516 handle: *mut c_void,
517 area_id: u32,
518 data: *const u64,
519 len: u32,
520) {
521 if handle.is_null() || data.is_null() { return; }
522 let state = unsafe { state_mut(handle) };
523 let style = state.take_style();
524 let data_vec = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec();
525 state.commands.push(WidgetCommand::Sparkline { area_id, data: data_vec, style });
526}
527
528/// Queues a [`Table`](ratatui::widgets::Table) with equal-width columns.
529///
530/// `data` format:
531/// - First line: tab-separated header cells.
532/// - Subsequent lines: one row per line; cells separated by tabs.
533///
534/// For typed column widths and row selection use [`ratatui_table_ex`].
535#[no_mangle]
536pub extern "C" fn ratatui_table(
537 handle: *mut c_void,
538 area_id: u32,
539 data: *const c_char,
540) {
541 if handle.is_null() { return; }
542 let state = unsafe { state_mut(handle) };
543 let style = state.take_style();
544 state.commands.push(WidgetCommand::Table {
545 area_id,
546 data: unsafe { cstr_to_string(data) },
547 style,
548 });
549}
550
551// ─── New widgets: BarChart, LineGauge, Scrollbar, Calendar, TableEx ──────────
552
553/// Queues a [`BarChart`](ratatui::widgets::BarChart).
554///
555/// `data` format: one bar per line, label and value separated by a tab.
556/// Malformed lines (missing tab or non-numeric value) are silently skipped.
557///
558/// # Parameters
559/// - `bar_width`: width of each bar in cells.
560/// - `bar_gap`: gap between bars in cells.
561#[no_mangle]
562pub extern "C" fn ratatui_barchart(
563 handle: *mut c_void,
564 area_id: u32,
565 data: *const c_char,
566 bar_width: u16,
567 bar_gap: u16,
568) {
569 if handle.is_null() { return; }
570 let state = unsafe { state_mut(handle) };
571 let style = state.take_style();
572 let data_str = unsafe { cstr_to_string(data) };
573 let bars: Vec<(String, u64)> = data_str
574 .lines()
575 .filter_map(|line| {
576 let mut parts = line.splitn(2, '\t');
577 let label = parts.next()?.to_string();
578 let value: u64 = parts.next()?.trim().parse().ok()?;
579 Some((label, value))
580 })
581 .collect();
582 state.commands.push(WidgetCommand::BarChart { area_id, bars, bar_width, bar_gap, style });
583}
584
585/// Queues a horizontal single-line [`LineGauge`](ratatui::widgets::LineGauge).
586///
587/// # Parameters
588/// - `ratio`: progress in `[0.0, 1.0]`; values outside the range are clamped.
589/// - `label`: text shown next to the gauge (pass `null` or empty for none).
590#[no_mangle]
591pub extern "C" fn ratatui_line_gauge(
592 handle: *mut c_void,
593 area_id: u32,
594 ratio: f32,
595 label: *const c_char,
596) {
597 if handle.is_null() { return; }
598 let state = unsafe { state_mut(handle) };
599 let style = state.take_style();
600 state.commands.push(WidgetCommand::LineGauge {
601 area_id,
602 ratio: ratio as f64,
603 label: unsafe { cstr_to_string(label) },
604 style,
605 });
606}
607
608/// Queues a [`Scrollbar`](ratatui::widgets::Scrollbar).
609///
610/// # Parameters
611/// - `content_length`: total scrollable length in cells.
612/// - `position`: current scroll offset in cells (`0..=content_length`).
613/// - `viewport_length`: visible portion of the content in cells.
614/// - `orientation`: `0` VerticalRight, `1` VerticalLeft, `2` HorizontalBottom,
615/// `3` HorizontalTop.
616#[no_mangle]
617pub extern "C" fn ratatui_scrollbar(
618 handle: *mut c_void,
619 area_id: u32,
620 content_length: u32,
621 position: u32,
622 viewport_length: u32,
623 orientation: u8,
624) {
625 if handle.is_null() { return; }
626 let state = unsafe { state_mut(handle) };
627 state.commands.push(WidgetCommand::Scrollbar {
628 area_id,
629 content_length,
630 position,
631 viewport_length,
632 orientation,
633 });
634}
635
636/// Queues a monthly calendar
637/// ([`Monthly`](ratatui::widgets::calendar::Monthly)).
638///
639/// Invalid dates fall back to January 1 of `year`, and if that also fails,
640/// to 2024-01-01. The `widget-calendar` Cargo feature must be enabled
641/// (it is, by default, in this crate).
642///
643/// # Parameters
644/// - `year`: full year (e.g. `2026`).
645/// - `month`: `1..=12`.
646/// - `day`: `1..=28` (later days are clamped to `28` to avoid month overflow).
647#[no_mangle]
648pub extern "C" fn ratatui_calendar(
649 handle: *mut c_void,
650 area_id: u32,
651 year: i32,
652 month: u8,
653 day: u8,
654) {
655 if handle.is_null() { return; }
656 let state = unsafe { state_mut(handle) };
657 state.commands.push(WidgetCommand::Calendar { area_id, year, month, day });
658}
659
660/// Queues an extended [`Table`](ratatui::widgets::Table) with typed column
661/// widths and optional row highlighting.
662///
663/// `data` follows the same format as [`ratatui_table`] (first line headers,
664/// subsequent lines rows; tab-separated cells).
665///
666/// # Parameters
667/// - `col_types` / `col_values`: parallel arrays of length `col_count`
668/// describing each column's constraint kind and value. Same encoding as
669/// [`ratatui_split`]. Pass `null` (or `col_count == 0`) for equal-width
670/// distribution.
671/// - `selected_row`: zero-based index of the highlighted row, or `-1` for
672/// no selection. The highlight uses a bold modifier.
673#[no_mangle]
674pub extern "C" fn ratatui_table_ex(
675 handle: *mut c_void,
676 area_id: u32,
677 data: *const c_char,
678 col_types: *const u8,
679 col_values: *const u16,
680 col_count: u32,
681 selected_row: i32,
682) {
683 if handle.is_null() { return; }
684 let state = unsafe { state_mut(handle) };
685 let style = state.take_style();
686 let col_constraints: Vec<(u8, u16)> =
687 if col_types.is_null() || col_values.is_null() || col_count == 0 {
688 Vec::new()
689 } else {
690 let types = unsafe { std::slice::from_raw_parts(col_types, col_count as usize) };
691 let values = unsafe { std::slice::from_raw_parts(col_values, col_count as usize) };
692 types.iter().zip(values.iter()).map(|(&t, &v)| (t, v)).collect()
693 };
694 state.commands.push(WidgetCommand::TableEx {
695 area_id,
696 data: unsafe { cstr_to_string(data) },
697 col_constraints,
698 selected_row,
699 style,
700 });
701}
702
703// ─── StyledParagraph builder ─────────────────────────────────────────────────
704
705/// Starts a multi-style paragraph builder.
706///
707/// Builder lifecycle:
708/// 1. [`ratatui_styled_para_begin`] — open the builder for `area_id`.
709/// 2. Zero or more [`ratatui_styled_para_span`] calls — append styled spans
710/// to the current line.
711/// 3. Zero or more [`ratatui_styled_para_newline`] calls — start a new line.
712/// 4. [`ratatui_styled_para_end`] — flush the builder into the command queue.
713///
714/// Only one styled-paragraph builder may be active at a time per handle.
715/// Beginning a new one before `_end` discards the previous one.
716///
717/// # Parameters
718/// - `alignment`: `0` Left, `1` Center, `2` Right.
719/// - `wrap`: non-zero to enable word wrapping (`trim: false`).
720#[no_mangle]
721pub extern "C" fn ratatui_styled_para_begin(
722 handle: *mut c_void,
723 area_id: u32,
724 alignment: u8,
725 wrap: u8,
726) {
727 if handle.is_null() { return; }
728 let state = unsafe { state_mut(handle) };
729 state.pending_styled_para = Some(PendingStyledParagraph {
730 area_id,
731 alignment,
732 wrap: wrap != 0,
733 lines: vec![vec![]],
734 });
735}
736
737/// Appends a styled [`Span`](ratatui::text::Span) to the current line of the
738/// pending styled paragraph.
739///
740/// Does nothing if no builder is active. Style parameters follow the same
741/// encoding as [`ratatui_set_style`].
742#[no_mangle]
743pub extern "C" fn ratatui_styled_para_span(
744 handle: *mut c_void,
745 text: *const c_char,
746 fg_r: u8, fg_g: u8, fg_b: u8, use_default_fg: u8,
747 bg_r: u8, bg_g: u8, bg_b: u8, use_default_bg: u8,
748 modifiers: u8,
749) {
750 if handle.is_null() { return; }
751 let state = unsafe { state_mut(handle) };
752 if let Some(ref mut pending) = state.pending_styled_para {
753 let style = style_from_rgba(
754 fg_r, fg_g, fg_b, use_default_fg,
755 bg_r, bg_g, bg_b, use_default_bg,
756 modifiers,
757 );
758 let span = SpanInfo { text: unsafe { cstr_to_string(text) }, style };
759 if let Some(last_line) = pending.lines.last_mut() {
760 last_line.push(span);
761 }
762 }
763}
764
765/// Starts a new line in the pending styled paragraph.
766///
767/// Does nothing if no builder is active.
768#[no_mangle]
769pub extern "C" fn ratatui_styled_para_newline(handle: *mut c_void) {
770 if handle.is_null() { return; }
771 let state = unsafe { state_mut(handle) };
772 if let Some(ref mut pending) = state.pending_styled_para {
773 pending.lines.push(vec![]);
774 }
775}
776
777/// Finalizes the pending styled paragraph and queues it for rendering.
778///
779/// Does nothing if no builder is active.
780#[no_mangle]
781pub extern "C" fn ratatui_styled_para_end(handle: *mut c_void) {
782 if handle.is_null() { return; }
783 let state = unsafe { state_mut(handle) };
784 if let Some(pending) = state.pending_styled_para.take() {
785 state.commands.push(WidgetCommand::StyledParagraph {
786 area_id: pending.area_id,
787 alignment: pending.alignment,
788 wrap: pending.wrap,
789 lines: pending.lines,
790 });
791 }
792}
793
794// ─── Chart builder ────────────────────────────────────────────────────────────
795
796/// Starts a [`Chart`](ratatui::widgets::Chart) builder.
797///
798/// Builder lifecycle:
799/// 1. [`ratatui_chart_begin`] — open the builder for `area_id`.
800/// 2. Optionally [`ratatui_chart_x_axis`] and/or [`ratatui_chart_y_axis`] —
801/// set axis titles and bounds.
802/// 3. Zero or more [`ratatui_chart_dataset`] calls — add datasets.
803/// 4. [`ratatui_chart_end`] — flush the builder into the command queue.
804///
805/// Only one chart builder may be active at a time per handle.
806#[no_mangle]
807pub extern "C" fn ratatui_chart_begin(handle: *mut c_void, area_id: u32) {
808 if handle.is_null() { return; }
809 let state = unsafe { state_mut(handle) };
810 state.pending_chart = Some(PendingChart {
811 area_id,
812 x_axis: None,
813 y_axis: None,
814 datasets: Vec::new(),
815 });
816}
817
818/// Sets the X axis title and `[min, max]` data bounds of the pending chart.
819///
820/// Does nothing if no chart builder is active.
821#[no_mangle]
822pub extern "C" fn ratatui_chart_x_axis(
823 handle: *mut c_void,
824 title: *const c_char,
825 min: f64,
826 max: f64,
827) {
828 if handle.is_null() { return; }
829 let state = unsafe { state_mut(handle) };
830 if let Some(ref mut pending) = state.pending_chart {
831 pending.x_axis = Some(AxisInfo { title: unsafe { cstr_to_string(title) }, min, max });
832 }
833}
834
835/// Sets the Y axis title and `[min, max]` data bounds of the pending chart.
836///
837/// Does nothing if no chart builder is active.
838#[no_mangle]
839pub extern "C" fn ratatui_chart_y_axis(
840 handle: *mut c_void,
841 title: *const c_char,
842 min: f64,
843 max: f64,
844) {
845 if handle.is_null() { return; }
846 let state = unsafe { state_mut(handle) };
847 if let Some(ref mut pending) = state.pending_chart {
848 pending.y_axis = Some(AxisInfo { title: unsafe { cstr_to_string(title) }, min, max });
849 }
850}
851
852/// Adds a [`Dataset`](ratatui::widgets::Dataset) to the pending chart.
853///
854/// # Parameters
855/// - `name`: dataset legend label.
856/// - `marker`: `0` Dot, `1` Braille, `2` HalfBlock, `3` Block.
857/// - `r`, `g`, `b`: dataset color.
858/// - `data`: pointer to `point_count * 2` `f64` values, interleaved as
859/// `[x0, y0, x1, y1, …]`.
860/// - `point_count`: number of `(x, y)` pairs.
861///
862/// Does nothing if no chart builder is active or `data` is null.
863#[no_mangle]
864pub extern "C" fn ratatui_chart_dataset(
865 handle: *mut c_void,
866 name: *const c_char,
867 marker: u8,
868 r: u8, g: u8, b: u8,
869 data: *const f64,
870 point_count: u32,
871) {
872 if handle.is_null() || data.is_null() { return; }
873 let state = unsafe { state_mut(handle) };
874 if let Some(ref mut pending) = state.pending_chart {
875 // Multiply in usize: `point_count * 2` can overflow u32.
876 let raw = unsafe { std::slice::from_raw_parts(data, point_count as usize * 2) };
877 let points: Vec<(f64, f64)> = raw.chunks(2).map(|c| (c[0], c[1])).collect();
878 pending.datasets.push(DatasetInfo {
879 name: unsafe { cstr_to_string(name) },
880 marker,
881 r, g, b,
882 points,
883 });
884 }
885}
886
887/// Finalizes the pending chart and queues it for rendering.
888///
889/// Does nothing if no chart builder is active.
890#[no_mangle]
891pub extern "C" fn ratatui_chart_end(handle: *mut c_void) {
892 if handle.is_null() { return; }
893 let state = unsafe { state_mut(handle) };
894 if let Some(pending) = state.pending_chart.take() {
895 state.commands.push(WidgetCommand::Chart {
896 area_id: pending.area_id,
897 x_axis: pending.x_axis,
898 y_axis: pending.y_axis,
899 datasets: pending.datasets,
900 });
901 }
902}
903
904// ─── Canvas builder ───────────────────────────────────────────────────────────
905
906/// Starts a [`Canvas`](ratatui::widgets::canvas::Canvas) builder.
907///
908/// Builder lifecycle:
909/// 1. [`ratatui_canvas_begin`] — open the builder for `area_id` with the
910/// given data-space bounds and marker style.
911/// 2. Zero or more shape calls — [`ratatui_canvas_map`],
912/// [`ratatui_canvas_line`], [`ratatui_canvas_circle`],
913/// [`ratatui_canvas_rectangle`], [`ratatui_canvas_text`],
914/// [`ratatui_canvas_points`], [`ratatui_canvas_layer`].
915/// 3. [`ratatui_canvas_end`] — flush the builder into the command queue.
916///
917/// Only one canvas builder may be active at a time per handle.
918///
919/// # Parameters
920/// - `x_min`, `x_max`, `y_min`, `y_max`: data-space bounds mapped onto the
921/// area.
922/// - `marker`: `0` Dot, `1` Braille, `2` HalfBlock, `3` Block.
923#[no_mangle]
924pub extern "C" fn ratatui_canvas_begin(
925 handle: *mut c_void,
926 area_id: u32,
927 x_min: f64, x_max: f64,
928 y_min: f64, y_max: f64,
929 marker: u8,
930) {
931 if handle.is_null() { return; }
932 let state = unsafe { state_mut(handle) };
933 state.pending_canvas = Some(PendingCanvas {
934 area_id,
935 x_min, x_max, y_min, y_max,
936 marker,
937 shapes: Vec::new(),
938 });
939}
940
941/// Draws the world map on the pending canvas.
942///
943/// # Parameters
944/// - `resolution`: `0` Low, any other value High.
945///
946/// Does nothing if no canvas builder is active.
947#[no_mangle]
948pub extern "C" fn ratatui_canvas_map(handle: *mut c_void, resolution: u8) {
949 if handle.is_null() { return; }
950 let state = unsafe { state_mut(handle) };
951 if let Some(ref mut p) = state.pending_canvas {
952 p.shapes.push(CanvasShape::Map { resolution });
953 }
954}
955
956/// Flushes the current canvas layer.
957///
958/// Subsequent shapes are drawn on a new layer on top of all previously drawn
959/// content. Does nothing if no canvas builder is active.
960#[no_mangle]
961pub extern "C" fn ratatui_canvas_layer(handle: *mut c_void) {
962 if handle.is_null() { return; }
963 let state = unsafe { state_mut(handle) };
964 if let Some(ref mut p) = state.pending_canvas { p.shapes.push(CanvasShape::Layer); }
965}
966
967/// Draws a colored line from `(x1, y1)` to `(x2, y2)` on the pending canvas.
968///
969/// Coordinates are in data space (see [`ratatui_canvas_begin`]).
970#[no_mangle]
971pub extern "C" fn ratatui_canvas_line(
972 handle: *mut c_void,
973 x1: f64, y1: f64, x2: f64, y2: f64,
974 r: u8, g: u8, b: u8,
975) {
976 if handle.is_null() { return; }
977 let state = unsafe { state_mut(handle) };
978 if let Some(ref mut p) = state.pending_canvas {
979 p.shapes.push(CanvasShape::Line { x1, y1, x2, y2, r, g, b });
980 }
981}
982
983/// Draws a colored circle centered at `(x, y)` with the given `radius`.
984///
985/// Coordinates are in data space (see [`ratatui_canvas_begin`]).
986#[no_mangle]
987pub extern "C" fn ratatui_canvas_circle(
988 handle: *mut c_void,
989 x: f64, y: f64, radius: f64,
990 r: u8, g: u8, b: u8,
991) {
992 if handle.is_null() { return; }
993 let state = unsafe { state_mut(handle) };
994 if let Some(ref mut p) = state.pending_canvas {
995 p.shapes.push(CanvasShape::Circle { x, y, radius, r, g, b });
996 }
997}
998
999/// Draws a colored rectangle outline anchored at `(x, y)` with size `(w, h)`.
1000///
1001/// Coordinates are in data space (see [`ratatui_canvas_begin`]).
1002#[no_mangle]
1003pub extern "C" fn ratatui_canvas_rectangle(
1004 handle: *mut c_void,
1005 x: f64, y: f64, w: f64, h: f64,
1006 r: u8, g: u8, b: u8,
1007) {
1008 if handle.is_null() { return; }
1009 let state = unsafe { state_mut(handle) };
1010 if let Some(ref mut p) = state.pending_canvas {
1011 p.shapes.push(CanvasShape::Rectangle { x, y, w, h, r, g, b });
1012 }
1013}
1014
1015/// Draws colored text anchored at `(x, y)` on the pending canvas.
1016///
1017/// Coordinates are in data space (see [`ratatui_canvas_begin`]).
1018#[no_mangle]
1019pub extern "C" fn ratatui_canvas_text(
1020 handle: *mut c_void,
1021 x: f64, y: f64,
1022 text: *const c_char,
1023 r: u8, g: u8, b: u8,
1024) {
1025 if handle.is_null() { return; }
1026 let state = unsafe { state_mut(handle) };
1027 if let Some(ref mut p) = state.pending_canvas {
1028 p.shapes.push(CanvasShape::Text { x, y, text: unsafe { cstr_to_string(text) }, r, g, b });
1029 }
1030}
1031
1032/// Draws a colored point cloud on the pending canvas.
1033///
1034/// # Parameters
1035/// - `coords`: pointer to `count * 2` `f64` values, interleaved as
1036/// `[x0, y0, x1, y1, …]`.
1037/// - `count`: number of `(x, y)` pairs.
1038///
1039/// Coordinates are in data space (see [`ratatui_canvas_begin`]). Does nothing
1040/// if no canvas builder is active or `coords` is null.
1041#[no_mangle]
1042pub extern "C" fn ratatui_canvas_points(
1043 handle: *mut c_void,
1044 coords: *const f64,
1045 count: u32,
1046 r: u8, g: u8, b: u8,
1047) {
1048 if handle.is_null() || coords.is_null() { return; }
1049 let state = unsafe { state_mut(handle) };
1050 if let Some(ref mut p) = state.pending_canvas {
1051 // Multiply in usize: `count * 2` can overflow u32.
1052 let raw = unsafe { std::slice::from_raw_parts(coords, count as usize * 2) };
1053 let pts: Vec<(f64, f64)> = raw.chunks(2).map(|c| (c[0], c[1])).collect();
1054 p.shapes.push(CanvasShape::Points { coords: pts, r, g, b });
1055 }
1056}
1057
1058/// Finalizes the pending canvas and queues it for rendering.
1059///
1060/// Does nothing if no canvas builder is active.
1061#[no_mangle]
1062pub extern "C" fn ratatui_canvas_end(handle: *mut c_void) {
1063 if handle.is_null() { return; }
1064 let state = unsafe { state_mut(handle) };
1065 if let Some(pending) = state.pending_canvas.take() {
1066 state.commands.push(WidgetCommand::Canvas {
1067 area_id: pending.area_id,
1068 x_min: pending.x_min,
1069 x_max: pending.x_max,
1070 y_min: pending.y_min,
1071 y_max: pending.y_max,
1072 marker: pending.marker,
1073 shapes: pending.shapes,
1074 });
1075 }
1076}
1077
1078// ─── Input / Hit-Testing ─────────────────────────────────────────────────────
1079
1080/// Returns the most specific area id covering the given terminal cell.
1081///
1082/// When several registered areas contain `(col, row)` the one with the
1083/// smallest cell count (the most deeply nested) wins. Returns `0` (root) when
1084/// no registered area matches.
1085///
1086/// Useful for mapping pointer input back into the layout tree.
1087#[no_mangle]
1088pub extern "C" fn ratatui_hit_test(
1089 handle: *mut c_void,
1090 col: u16,
1091 row: u16,
1092) -> u32 {
1093 if handle.is_null() { return 0; }
1094 let state = unsafe { state_mut(handle) };
1095 let mut best_id = 0u32;
1096 let mut best_area = u32::MAX;
1097
1098 for (&id, &rect) in &state.area_map {
1099 if col >= rect.x && col < rect.x + rect.width
1100 && row >= rect.y && row < rect.y + rect.height
1101 {
1102 let area = (rect.width as u32) * (rect.height as u32);
1103 if area < best_area {
1104 best_area = area;
1105 best_id = id;
1106 }
1107 }
1108 }
1109 best_id
1110}
1111
1112/// Returns the cell-space rectangle of the given area as a packed `u64`.
1113///
1114/// The four `u16` fields are packed little-endian:
1115///
1116/// ```text
1117/// bits 0..16 -> x
1118/// bits 16..32 -> y
1119/// bits 32..48 -> width
1120/// bits 48..64 -> height
1121/// ```
1122///
1123/// Returns `0` if `handle` is null or the area id is unknown.
1124#[no_mangle]
1125pub extern "C" fn ratatui_get_area_rect(
1126 handle: *const c_void,
1127 area_id: u32,
1128) -> u64 {
1129 if handle.is_null() { return 0; }
1130 let state = unsafe { &*(handle as *const TerminalState) };
1131 match state.area_map.get(&area_id) {
1132 Some(rect) => {
1133 (rect.x as u64)
1134 | ((rect.y as u64) << 16)
1135 | ((rect.width as u64) << 32)
1136 | ((rect.height as u64) << 48)
1137 }
1138 None => 0,
1139 }
1140}
1141
1142// ─── Utility ─────────────────────────────────────────────────────────────────
1143
1144/// Returns the library version as a static null-terminated C string.
1145///
1146/// The returned pointer is valid for the lifetime of the process and must
1147/// not be freed by the caller. The value matches `CARGO_PKG_VERSION`.
1148#[no_mangle]
1149pub extern "C" fn ratatui_version() -> *const c_char {
1150 concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use super::*;
1156 use std::ffi::CString;
1157
1158 /// Regression: 65536 tab-separated columns used to truncate `col_count`
1159 /// to 0 in the u16 cast and panic with a division by zero — and, before
1160 /// the column cap, stalled the layout solver for hours.
1161 #[test]
1162 fn table_with_more_than_u16_max_columns_does_not_panic() {
1163 let handle = ratatui_create(10, 5, 14.0);
1164 ratatui_begin_frame(handle);
1165 let data = CString::new(vec!["h"; 65536].join("\t")).unwrap();
1166 ratatui_table(handle, 0, data.as_ptr());
1167 ratatui_end_frame(handle);
1168 ratatui_destroy(handle);
1169 }
1170
1171 /// Regression: invalid font bytes must be rejected without panicking
1172 /// (the crate is built with `panic = "abort"` in release).
1173 #[test]
1174 fn set_custom_font_with_invalid_bytes_returns_zero() {
1175 let handle = ratatui_create(10, 5, 14.0);
1176 let bytes = [0u8; 16];
1177 assert_eq!(ratatui_set_custom_font(handle, bytes.as_ptr(), bytes.len() as u32), 0);
1178 ratatui_destroy(handle);
1179 }
1180
1181 /// Regression: after a successful font swap the reported pixel dimensions
1182 /// must match the rasterized buffer size.
1183 #[test]
1184 fn set_custom_font_resyncs_pixel_dimensions() {
1185 let handle = ratatui_create(10, 5, 14.0);
1186 let bytes = include_bytes!("../fonts/JetBrainsMono-Regular.ttf");
1187 assert_eq!(
1188 ratatui_set_custom_font(handle, bytes.as_ptr(), bytes.len() as u32),
1189 1
1190 );
1191 let w = ratatui_pixel_width(handle);
1192 let h = ratatui_pixel_height(handle);
1193 ratatui_begin_frame(handle);
1194 ratatui_end_frame(handle);
1195 let state = unsafe { &*(handle as *const TerminalState) };
1196 assert_eq!(state.pixel_buffer.len(), w as usize * h as usize * 3);
1197 ratatui_destroy(handle);
1198 }
1199}