Skip to content

Commit cd76b22

Browse files
authored
feat(components): intrinsic measurement for terminal, table and codeblock (#35)
Closes #11. Painter-accurate intrinsics for the three content-heavy components, painter constants shared instead of duplicated, accidental-pass geometry test fixed.
1 parent 68dbf19 commit cd76b22

9 files changed

Lines changed: 453 additions & 17 deletions

File tree

crates/rustmotion-cli/src/commands/geometry.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,9 @@ mod tests {
859859
#[test]
860860
fn auto_scroll_disabled_codeblock_overflows() {
861861
// 20 lines × 14 px × 1.5 line-height + chrome + padding ≈ 487 px.
862-
// Box height capped at 200 → AutoScrollDisabledOverflow.
862+
// Box height capped at 200 via style.height → AutoScrollDisabledOverflow.
863+
// Note: "size" is a legacy field silently ignored by the schema; use
864+
// style.height to actually constrain the box in the layout pass.
863865
let json = r##"{
864866
"video": { "width": 1920, "height": 1080 },
865867
"scenes": [{
@@ -868,7 +870,7 @@ mod tests {
868870
"type": "codeblock",
869871
"code": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20",
870872
"auto_scroll": false,
871-
"size": { "width": 800, "height": 200 },
873+
"style": { "width": "800px", "height": "200px" },
872874
"x": 100, "y": 100
873875
}]
874876
}]

crates/rustmotion-components/src/box_builder.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,13 @@ fn component_intrinsic(
306306
c,
307307
))),
308308
Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))),
309+
Terminal(t) => Some(Arc::new(
310+
crate::intrinsic::TerminalIntrinsic::from_terminal(t),
311+
)),
312+
Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))),
313+
Codeblock(c) => Some(Arc::new(
314+
crate::intrinsic::CodeblockIntrinsic::from_codeblock(c),
315+
)),
309316
_ => None,
310317
}
311318
}

crates/rustmotion-components/src/codeblock/dimensions.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ use super::Codeblock;
44

55
/// Computed dimensions for a code block
66
#[allow(dead_code)]
7-
pub(super) struct CodeDimensions {
8-
pub(super) line_count: usize,
9-
pub(super) max_line_width: f32,
10-
pub(super) gutter_width: f32,
11-
pub(super) total_width: f32,
12-
pub(super) total_height: f32,
7+
pub(crate) struct CodeDimensions {
8+
pub(crate) line_count: usize,
9+
pub(crate) max_line_width: f32,
10+
pub(crate) gutter_width: f32,
11+
pub(crate) total_width: f32,
12+
pub(crate) total_height: f32,
1313
}
1414

15-
pub(super) fn compute_code_dimensions(
15+
pub(crate) fn compute_code_dimensions(
1616
code: &str,
1717
font: &Font,
1818
padding: (f32, f32, f32, f32),

crates/rustmotion-components/src/codeblock/highlight.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ pub(super) fn highlight_code(code: &str, language: &str, theme: &Theme) -> Vec<H
526526
result
527527
}
528528

529-
pub(super) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight) -> Option<Font> {
529+
pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight) -> Option<Font> {
530530
let font_mgr = rustmotion_core::engine::renderer::font_mgr();
531531
let w: i32 = match weight {
532532
FontWeight::Normal => 400,

crates/rustmotion-components/src/codeblock/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
mod chrome;
22
mod diff;
3-
mod dimensions;
4-
mod highlight;
3+
pub(crate) mod dimensions;
4+
pub(crate) mod highlight;
55
mod render;
66
mod reveal;
77

crates/rustmotion-components/src/intrinsic.rs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,260 @@ fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) -
339339
#[allow(dead_code)]
340340
fn _line_height_unused(_: Option<&LineHeight>) {}
341341

342+
// ─────────────────────────────────────────────────────────────────────────────
343+
// Terminal intrinsic measurer
344+
// ─────────────────────────────────────────────────────────────────────────────
345+
346+
use crate::terminal::{
347+
Terminal, CHROME_HEIGHT, FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT,
348+
PADDING as TERM_PADDING,
349+
};
350+
351+
/// Intrinsic measurer for [`Terminal`].
352+
///
353+
/// Natural size formula (matches the painter exactly):
354+
/// - `line_height = ceil(font_size × TERM_LINE_HEIGHT / TERM_FONT_SIZE)`
355+
/// - `height = chrome_height + 2 × TERM_PADDING + n_lines × line_height`
356+
/// - `width` = widest line text (prefix + content) + 2 × TERM_PADDING
357+
///
358+
/// If the Skia font fails to load, returns (0, 0) so layout falls back to
359+
/// whatever container constraints supply.
360+
pub struct TerminalIntrinsic {
361+
line_height: f32,
362+
n_lines: usize,
363+
chrome_height: f32,
364+
padding: f32,
365+
/// Maximum measured text width across all lines (including prefix).
366+
max_line_width: f32,
367+
}
368+
369+
impl TerminalIntrinsic {
370+
pub fn from_terminal(t: &Terminal) -> Self {
371+
let font_size = t.style.font_size_px_or(TERM_FONT_SIZE);
372+
let line_height = (font_size * TERM_LINE_HEIGHT / TERM_FONT_SIZE).ceil();
373+
let chrome_height = if t.show_chrome { CHROME_HEIGHT } else { 0.0 };
374+
375+
// Measure each line (prefix + text) with the same Skia font the painter uses.
376+
let max_line_width = Self::measure_max_width(t, font_size);
377+
378+
Self {
379+
line_height,
380+
n_lines: t.lines.len(),
381+
chrome_height,
382+
padding: TERM_PADDING,
383+
max_line_width,
384+
}
385+
}
386+
387+
fn measure_max_width(t: &Terminal, font_size: f32) -> f32 {
388+
let font_style = skia_safe::FontStyle::normal();
389+
let Ok(typeface) = typeface_with_fallback("SF Mono", font_style) else {
390+
// Font unavailable (CI without fonts); return 0 — the layout will
391+
// be width-unconstrained and the container drives the size.
392+
return 0.0;
393+
};
394+
let font = Font::from_typeface(typeface, font_size);
395+
let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
396+
397+
t.lines
398+
.iter()
399+
.map(|line| {
400+
let prefix = match line.line_type {
401+
crate::terminal::TerminalLineType::Prompt => "$ ",
402+
_ => "",
403+
};
404+
let full = format!("{}{}", prefix, line.text);
405+
measure_text_with_fallback(&full, &font, &emoji_font, 0.0)
406+
})
407+
.fold(0.0f32, f32::max)
408+
}
409+
}
410+
411+
impl IntrinsicMeasure for TerminalIntrinsic {
412+
fn measure(
413+
&self,
414+
known: (Option<f32>, Option<f32>),
415+
_available: (AvailableSpace, AvailableSpace),
416+
) -> (f32, f32) {
417+
let w = known.0.unwrap_or(self.max_line_width + self.padding * 2.0);
418+
let h = known.1.unwrap_or(
419+
self.chrome_height + self.padding * 2.0 + self.n_lines as f32 * self.line_height,
420+
);
421+
(w, h)
422+
}
423+
}
424+
425+
// ─────────────────────────────────────────────────────────────────────────────
426+
// Table intrinsic measurer
427+
// ─────────────────────────────────────────────────────────────────────────────
428+
429+
use crate::table::{
430+
Table, DEFAULT_CELL_PADDING, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO,
431+
};
432+
433+
/// Intrinsic measurer for [`Table`].
434+
///
435+
/// Natural size formula (matches the painter exactly):
436+
/// - `row_height = font_size × DEFAULT_ROW_HEIGHT_RATIO`
437+
/// - `height = (1 + row_count) × row_height` (header + data rows)
438+
/// - `width`: if `column_widths` are provided, their sum; otherwise each
439+
/// column gets `max(header_text_width + 2 × cell_padding, min_col_width)`.
440+
pub struct TableIntrinsic {
441+
row_height: f32,
442+
row_count: usize, // data rows only; header adds 1
443+
total_width: f32,
444+
}
445+
446+
impl TableIntrinsic {
447+
pub fn from_table(t: &Table) -> Self {
448+
let font_size = t.style.font_size_px_or(TABLE_FONT_SIZE);
449+
let row_height = font_size * DEFAULT_ROW_HEIGHT_RATIO;
450+
451+
let total_width = Self::compute_width(t, font_size);
452+
453+
Self {
454+
row_height,
455+
row_count: t.rows.len(),
456+
total_width,
457+
}
458+
}
459+
460+
fn compute_width(t: &Table, font_size: f32) -> f32 {
461+
// Explicit column widths provided → sum them.
462+
if let Some(widths) = &t.column_widths {
463+
if !widths.is_empty() {
464+
return widths.iter().sum();
465+
}
466+
}
467+
468+
// Measure each header with the bold font; add 2× cell_padding per column.
469+
let font_style = skia_safe::FontStyle::bold();
470+
let family = t.style.font_family.as_deref().unwrap_or("Inter");
471+
let Ok(typeface) = typeface_with_fallback(family, font_style) else {
472+
// Font unavailable: fall back to col_count × a reasonable minimum.
473+
let col_count = t.headers.len().max(1) as f32;
474+
return col_count * (TABLE_FONT_SIZE * 8.0 + DEFAULT_CELL_PADDING * 2.0);
475+
};
476+
let font = Font::from_typeface(typeface, font_size);
477+
let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
478+
let cell_padding = t.cell_padding;
479+
480+
// Also consider data cell widths to size columns appropriately.
481+
let col_count = t.headers.len().max(1);
482+
let mut col_widths: Vec<f32> = vec![0.0; col_count];
483+
484+
for (i, header) in t.headers.iter().enumerate() {
485+
let w = measure_text_with_fallback(header, &font, &emoji_font, 0.0);
486+
col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
487+
}
488+
for row in &t.rows {
489+
for (i, cell) in row.iter().enumerate() {
490+
if i >= col_count {
491+
break;
492+
}
493+
let w = measure_text_with_fallback(cell, &font, &emoji_font, 0.0);
494+
col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
495+
}
496+
}
497+
498+
col_widths.iter().sum()
499+
}
500+
}
501+
502+
impl IntrinsicMeasure for TableIntrinsic {
503+
fn measure(
504+
&self,
505+
known: (Option<f32>, Option<f32>),
506+
_available: (AvailableSpace, AvailableSpace),
507+
) -> (f32, f32) {
508+
let w = known.0.unwrap_or(self.total_width);
509+
let h = known
510+
.1
511+
.unwrap_or((1 + self.row_count) as f32 * self.row_height);
512+
(w, h)
513+
}
514+
}
515+
516+
// ─────────────────────────────────────────────────────────────────────────────
517+
// Codeblock intrinsic measurer
518+
// ─────────────────────────────────────────────────────────────────────────────
519+
520+
use crate::codeblock::dimensions::compute_code_dimensions;
521+
use crate::codeblock::highlight::resolve_monospace_font;
522+
use crate::codeblock::Codeblock;
523+
use rustmotion_core::css::style::{FontWeight as CssFontWeight2, FontWeightKw as CssFontWeightKw2};
524+
use rustmotion_core::schema::FontWeight;
525+
526+
/// Intrinsic measurer for [`Codeblock`].
527+
///
528+
/// Reuses `compute_code_dimensions` (same function as the painter) to derive:
529+
/// - `width = max_line_width + gutter_width + pad_left + pad_right`
530+
/// - `height = line_count × line_height + pad_top + pad_bottom + chrome_height`
531+
///
532+
/// Computed once at construction from the initial `code` string. If a state
533+
/// transition widens the content at paint time, `auto_scroll` handles vertical
534+
/// overflow without needing the intrinsic to re-run.
535+
pub struct CodeblockIntrinsic {
536+
natural_width: f32,
537+
natural_height: f32,
538+
}
539+
540+
impl CodeblockIntrinsic {
541+
pub fn from_codeblock(c: &Codeblock) -> Self {
542+
let font_family = c.style.font_family_or("JetBrains Mono");
543+
let font_size = c.style.font_size_px_or(14.0);
544+
let font_weight = match &c.style.font_weight {
545+
Some(CssFontWeight2::Keyword(CssFontWeightKw2::Bold | CssFontWeightKw2::Bolder)) => {
546+
FontWeight::Bold
547+
}
548+
Some(CssFontWeight2::Number(n)) if *n >= 600 => FontWeight::Bold,
549+
Some(CssFontWeight2::Number(n)) => FontWeight::Weight(*n),
550+
_ => FontWeight::Normal,
551+
};
552+
553+
let Some(font) = resolve_monospace_font(font_family, font_size, font_weight) else {
554+
return Self {
555+
natural_width: 0.0,
556+
natural_height: 0.0,
557+
};
558+
};
559+
560+
let padding = {
561+
let (t, r, b, l) = c.style.padding_px();
562+
if t == 0.0 && r == 0.0 && b == 0.0 && l == 0.0 {
563+
(16.0, 16.0, 16.0, 16.0)
564+
} else {
565+
(t, r, b, l)
566+
}
567+
};
568+
569+
let chrome_height = if c.chrome.as_ref().is_some_and(|ch| ch.enabled) {
570+
36.0
571+
} else {
572+
0.0
573+
};
574+
575+
let dims = compute_code_dimensions(&c.code, &font, padding, chrome_height, c);
576+
577+
Self {
578+
natural_width: dims.total_width,
579+
natural_height: dims.total_height,
580+
}
581+
}
582+
}
583+
584+
impl IntrinsicMeasure for CodeblockIntrinsic {
585+
fn measure(
586+
&self,
587+
known: (Option<f32>, Option<f32>),
588+
_available: (AvailableSpace, AvailableSpace),
589+
) -> (f32, f32) {
590+
let w = known.0.unwrap_or(self.natural_width);
591+
let h = known.1.unwrap_or(self.natural_height);
592+
(w, h)
593+
}
594+
}
595+
342596
#[cfg(test)]
343597
mod tests {
344598
use super::*;

crates/rustmotion-components/src/table.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@ pub enum ColumnAlign {
2323
Right,
2424
}
2525

26+
pub(crate) const DEFAULT_FONT_SIZE: f32 = 14.0;
27+
/// row_height = DEFAULT_FONT_SIZE * 2.5
28+
pub(crate) const DEFAULT_ROW_HEIGHT_RATIO: f32 = 2.5;
29+
pub(crate) const DEFAULT_CELL_PADDING: f32 = 12.0;
30+
2631
fn default_cell_padding() -> f32 {
27-
12.0
32+
DEFAULT_CELL_PADDING
2833
}
2934

3035
fn default_show_borders() -> bool {

crates/rustmotion-components/src/terminal.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,10 @@ rustmotion_core::impl_traits!(Terminal {
125125
});
126126

127127
pub const CORNER_RADIUS: f32 = 10.0;
128-
const CHROME_HEIGHT: f32 = 36.0;
129-
const FONT_SIZE: f32 = 14.0;
130-
const LINE_HEIGHT: f32 = 22.0;
131-
const PADDING: f32 = 16.0;
128+
pub(crate) const CHROME_HEIGHT: f32 = 36.0;
129+
pub(crate) const FONT_SIZE: f32 = 14.0;
130+
pub(crate) const LINE_HEIGHT: f32 = 22.0;
131+
pub(crate) const PADDING: f32 = 16.0;
132132

133133
impl Terminal {
134134
fn make_font(&self) -> Option<skia_safe::Font> {

0 commit comments

Comments
 (0)