diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs index 3efce42..0f40eab 100644 --- a/crates/rustmotion-cli/src/commands/geometry.rs +++ b/crates/rustmotion-cli/src/commands/geometry.rs @@ -859,7 +859,9 @@ mod tests { #[test] fn auto_scroll_disabled_codeblock_overflows() { // 20 lines × 14 px × 1.5 line-height + chrome + padding ≈ 487 px. - // Box height capped at 200 → AutoScrollDisabledOverflow. + // Box height capped at 200 via style.height → AutoScrollDisabledOverflow. + // Note: "size" is a legacy field silently ignored by the schema; use + // style.height to actually constrain the box in the layout pass. let json = r##"{ "video": { "width": 1920, "height": 1080 }, "scenes": [{ @@ -868,7 +870,7 @@ mod tests { "type": "codeblock", "code": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20", "auto_scroll": false, - "size": { "width": 800, "height": 200 }, + "style": { "width": "800px", "height": "200px" }, "x": 100, "y": 100 }] }] diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 58f3e72..dff11e5 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -306,6 +306,13 @@ fn component_intrinsic( c, ))), Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))), + Terminal(t) => Some(Arc::new( + crate::intrinsic::TerminalIntrinsic::from_terminal(t), + )), + Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))), + Codeblock(c) => Some(Arc::new( + crate::intrinsic::CodeblockIntrinsic::from_codeblock(c), + )), _ => None, } } diff --git a/crates/rustmotion-components/src/codeblock/dimensions.rs b/crates/rustmotion-components/src/codeblock/dimensions.rs index 4526d34..3175a85 100644 --- a/crates/rustmotion-components/src/codeblock/dimensions.rs +++ b/crates/rustmotion-components/src/codeblock/dimensions.rs @@ -4,15 +4,15 @@ use super::Codeblock; /// Computed dimensions for a code block #[allow(dead_code)] -pub(super) struct CodeDimensions { - pub(super) line_count: usize, - pub(super) max_line_width: f32, - pub(super) gutter_width: f32, - pub(super) total_width: f32, - pub(super) total_height: f32, +pub(crate) struct CodeDimensions { + pub(crate) line_count: usize, + pub(crate) max_line_width: f32, + pub(crate) gutter_width: f32, + pub(crate) total_width: f32, + pub(crate) total_height: f32, } -pub(super) fn compute_code_dimensions( +pub(crate) fn compute_code_dimensions( code: &str, font: &Font, padding: (f32, f32, f32, f32), diff --git a/crates/rustmotion-components/src/codeblock/highlight.rs b/crates/rustmotion-components/src/codeblock/highlight.rs index 0432159..8dfc5cf 100644 --- a/crates/rustmotion-components/src/codeblock/highlight.rs +++ b/crates/rustmotion-components/src/codeblock/highlight.rs @@ -526,7 +526,7 @@ pub(super) fn highlight_code(code: &str, language: &str, theme: &Theme) -> Vec Option { +pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight) -> Option { let font_mgr = rustmotion_core::engine::renderer::font_mgr(); let w: i32 = match weight { FontWeight::Normal => 400, diff --git a/crates/rustmotion-components/src/codeblock/mod.rs b/crates/rustmotion-components/src/codeblock/mod.rs index 9d35d64..462fe1b 100644 --- a/crates/rustmotion-components/src/codeblock/mod.rs +++ b/crates/rustmotion-components/src/codeblock/mod.rs @@ -1,7 +1,7 @@ mod chrome; mod diff; -mod dimensions; -mod highlight; +pub(crate) mod dimensions; +pub(crate) mod highlight; mod render; mod reveal; diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index c325e6d..67f21b4 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -339,6 +339,260 @@ fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) - #[allow(dead_code)] fn _line_height_unused(_: Option<&LineHeight>) {} +// ───────────────────────────────────────────────────────────────────────────── +// Terminal intrinsic measurer +// ───────────────────────────────────────────────────────────────────────────── + +use crate::terminal::{ + Terminal, CHROME_HEIGHT, FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT, + PADDING as TERM_PADDING, +}; + +/// Intrinsic measurer for [`Terminal`]. +/// +/// Natural size formula (matches the painter exactly): +/// - `line_height = ceil(font_size × TERM_LINE_HEIGHT / TERM_FONT_SIZE)` +/// - `height = chrome_height + 2 × TERM_PADDING + n_lines × line_height` +/// - `width` = widest line text (prefix + content) + 2 × TERM_PADDING +/// +/// If the Skia font fails to load, returns (0, 0) so layout falls back to +/// whatever container constraints supply. +pub struct TerminalIntrinsic { + line_height: f32, + n_lines: usize, + chrome_height: f32, + padding: f32, + /// Maximum measured text width across all lines (including prefix). + max_line_width: f32, +} + +impl TerminalIntrinsic { + pub fn from_terminal(t: &Terminal) -> Self { + let font_size = t.style.font_size_px_or(TERM_FONT_SIZE); + let line_height = (font_size * TERM_LINE_HEIGHT / TERM_FONT_SIZE).ceil(); + let chrome_height = if t.show_chrome { CHROME_HEIGHT } else { 0.0 }; + + // Measure each line (prefix + text) with the same Skia font the painter uses. + let max_line_width = Self::measure_max_width(t, font_size); + + Self { + line_height, + n_lines: t.lines.len(), + chrome_height, + padding: TERM_PADDING, + max_line_width, + } + } + + fn measure_max_width(t: &Terminal, font_size: f32) -> f32 { + let font_style = skia_safe::FontStyle::normal(); + let Ok(typeface) = typeface_with_fallback("SF Mono", font_style) else { + // Font unavailable (CI without fonts); return 0 — the layout will + // be width-unconstrained and the container drives the size. + return 0.0; + }; + let font = Font::from_typeface(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); + + t.lines + .iter() + .map(|line| { + let prefix = match line.line_type { + crate::terminal::TerminalLineType::Prompt => "$ ", + _ => "", + }; + let full = format!("{}{}", prefix, line.text); + measure_text_with_fallback(&full, &font, &emoji_font, 0.0) + }) + .fold(0.0f32, f32::max) + } +} + +impl IntrinsicMeasure for TerminalIntrinsic { + fn measure( + &self, + known: (Option, Option), + _available: (AvailableSpace, AvailableSpace), + ) -> (f32, f32) { + let w = known.0.unwrap_or(self.max_line_width + self.padding * 2.0); + let h = known.1.unwrap_or( + self.chrome_height + self.padding * 2.0 + self.n_lines as f32 * self.line_height, + ); + (w, h) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Table intrinsic measurer +// ───────────────────────────────────────────────────────────────────────────── + +use crate::table::{ + Table, DEFAULT_CELL_PADDING, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO, +}; + +/// Intrinsic measurer for [`Table`]. +/// +/// Natural size formula (matches the painter exactly): +/// - `row_height = font_size × DEFAULT_ROW_HEIGHT_RATIO` +/// - `height = (1 + row_count) × row_height` (header + data rows) +/// - `width`: if `column_widths` are provided, their sum; otherwise each +/// column gets `max(header_text_width + 2 × cell_padding, min_col_width)`. +pub struct TableIntrinsic { + row_height: f32, + row_count: usize, // data rows only; header adds 1 + total_width: f32, +} + +impl TableIntrinsic { + pub fn from_table(t: &Table) -> Self { + let font_size = t.style.font_size_px_or(TABLE_FONT_SIZE); + let row_height = font_size * DEFAULT_ROW_HEIGHT_RATIO; + + let total_width = Self::compute_width(t, font_size); + + Self { + row_height, + row_count: t.rows.len(), + total_width, + } + } + + fn compute_width(t: &Table, font_size: f32) -> f32 { + // Explicit column widths provided → sum them. + if let Some(widths) = &t.column_widths { + if !widths.is_empty() { + return widths.iter().sum(); + } + } + + // Measure each header with the bold font; add 2× cell_padding per column. + let font_style = skia_safe::FontStyle::bold(); + let family = t.style.font_family.as_deref().unwrap_or("Inter"); + let Ok(typeface) = typeface_with_fallback(family, font_style) else { + // Font unavailable: fall back to col_count × a reasonable minimum. + let col_count = t.headers.len().max(1) as f32; + return col_count * (TABLE_FONT_SIZE * 8.0 + DEFAULT_CELL_PADDING * 2.0); + }; + let font = Font::from_typeface(typeface, font_size); + let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); + let cell_padding = t.cell_padding; + + // Also consider data cell widths to size columns appropriately. + let col_count = t.headers.len().max(1); + let mut col_widths: Vec = vec![0.0; col_count]; + + for (i, header) in t.headers.iter().enumerate() { + let w = measure_text_with_fallback(header, &font, &emoji_font, 0.0); + col_widths[i] = col_widths[i].max(w + cell_padding * 2.0); + } + for row in &t.rows { + for (i, cell) in row.iter().enumerate() { + if i >= col_count { + break; + } + let w = measure_text_with_fallback(cell, &font, &emoji_font, 0.0); + col_widths[i] = col_widths[i].max(w + cell_padding * 2.0); + } + } + + col_widths.iter().sum() + } +} + +impl IntrinsicMeasure for TableIntrinsic { + fn measure( + &self, + known: (Option, Option), + _available: (AvailableSpace, AvailableSpace), + ) -> (f32, f32) { + let w = known.0.unwrap_or(self.total_width); + let h = known + .1 + .unwrap_or((1 + self.row_count) as f32 * self.row_height); + (w, h) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Codeblock intrinsic measurer +// ───────────────────────────────────────────────────────────────────────────── + +use crate::codeblock::dimensions::compute_code_dimensions; +use crate::codeblock::highlight::resolve_monospace_font; +use crate::codeblock::Codeblock; +use rustmotion_core::css::style::{FontWeight as CssFontWeight2, FontWeightKw as CssFontWeightKw2}; +use rustmotion_core::schema::FontWeight; + +/// Intrinsic measurer for [`Codeblock`]. +/// +/// Reuses `compute_code_dimensions` (same function as the painter) to derive: +/// - `width = max_line_width + gutter_width + pad_left + pad_right` +/// - `height = line_count × line_height + pad_top + pad_bottom + chrome_height` +/// +/// Computed once at construction from the initial `code` string. If a state +/// transition widens the content at paint time, `auto_scroll` handles vertical +/// overflow without needing the intrinsic to re-run. +pub struct CodeblockIntrinsic { + natural_width: f32, + natural_height: f32, +} + +impl CodeblockIntrinsic { + pub fn from_codeblock(c: &Codeblock) -> Self { + let font_family = c.style.font_family_or("JetBrains Mono"); + let font_size = c.style.font_size_px_or(14.0); + let font_weight = match &c.style.font_weight { + Some(CssFontWeight2::Keyword(CssFontWeightKw2::Bold | CssFontWeightKw2::Bolder)) => { + FontWeight::Bold + } + Some(CssFontWeight2::Number(n)) if *n >= 600 => FontWeight::Bold, + Some(CssFontWeight2::Number(n)) => FontWeight::Weight(*n), + _ => FontWeight::Normal, + }; + + let Some(font) = resolve_monospace_font(font_family, font_size, font_weight) else { + return Self { + natural_width: 0.0, + natural_height: 0.0, + }; + }; + + let padding = { + let (t, r, b, l) = c.style.padding_px(); + if t == 0.0 && r == 0.0 && b == 0.0 && l == 0.0 { + (16.0, 16.0, 16.0, 16.0) + } else { + (t, r, b, l) + } + }; + + let chrome_height = if c.chrome.as_ref().is_some_and(|ch| ch.enabled) { + 36.0 + } else { + 0.0 + }; + + let dims = compute_code_dimensions(&c.code, &font, padding, chrome_height, c); + + Self { + natural_width: dims.total_width, + natural_height: dims.total_height, + } + } +} + +impl IntrinsicMeasure for CodeblockIntrinsic { + fn measure( + &self, + known: (Option, Option), + _available: (AvailableSpace, AvailableSpace), + ) -> (f32, f32) { + let w = known.0.unwrap_or(self.natural_width); + let h = known.1.unwrap_or(self.natural_height); + (w, h) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index 94a43b9..806eb99 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -23,8 +23,13 @@ pub enum ColumnAlign { Right, } +pub(crate) const DEFAULT_FONT_SIZE: f32 = 14.0; +/// row_height = DEFAULT_FONT_SIZE * 2.5 +pub(crate) const DEFAULT_ROW_HEIGHT_RATIO: f32 = 2.5; +pub(crate) const DEFAULT_CELL_PADDING: f32 = 12.0; + fn default_cell_padding() -> f32 { - 12.0 + DEFAULT_CELL_PADDING } fn default_show_borders() -> bool { diff --git a/crates/rustmotion-components/src/terminal.rs b/crates/rustmotion-components/src/terminal.rs index 689c239..668e176 100644 --- a/crates/rustmotion-components/src/terminal.rs +++ b/crates/rustmotion-components/src/terminal.rs @@ -125,10 +125,10 @@ rustmotion_core::impl_traits!(Terminal { }); pub const CORNER_RADIUS: f32 = 10.0; -const CHROME_HEIGHT: f32 = 36.0; -const FONT_SIZE: f32 = 14.0; -const LINE_HEIGHT: f32 = 22.0; -const PADDING: f32 = 16.0; +pub(crate) const CHROME_HEIGHT: f32 = 36.0; +pub(crate) const FONT_SIZE: f32 = 14.0; +pub(crate) const LINE_HEIGHT: f32 = 22.0; +pub(crate) const PADDING: f32 = 16.0; impl Terminal { fn make_font(&self) -> Option { diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 536bfa2..368d176 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -836,4 +836,172 @@ mod component_smoke { "SlideInLeft centroid did not move right (early_cx={early_cx:.1}, late_cx={late_cx:.1}, dx={dx:.1})" ); } + + // ────────────────────────────────────────────────────────────────────────── + // Intrinsic-measure tests for Terminal, Table, Codeblock + // + // Each test wraps the component in a flex card with NO explicit width/height, + // runs the full layout pass, and asserts that the component's BoxLayout has + // sensible non-zero dimensions derived from its content. + // ────────────────────────────────────────────────────────────────────────── + + /// Helper: build a scene containing one flex card wrapping one child (the + /// component under test, no explicit size), run layout at 1920×1080, and + /// return the BoxLayout of the *first child of the card* (the component). + fn layout_for_unsized_component_in_flex_card( + component_json: serde_json::Value, + ) -> rustmotion_core::engine::layout_pass::BoxLayout { + use crate::components::{ChildComponent, PositionMode}; + use rustmotion_components::box_builder::build_scene; + use rustmotion_components::card::Card; + use rustmotion_core::css::style::{CssStyle, FlexDirection}; + use rustmotion_core::css::taffy_bridge::ConversionContext; + use rustmotion_core::engine::layout_pass::run_layout; + + let component: Component = serde_json::from_value(component_json).expect("deserialize"); + let inner = ChildComponent { + component, + position: None, + x: None, + y: None, + z_index: None, + }; + + let card_style = CssStyle { + flex_direction: Some(FlexDirection::Column), + ..Default::default() + }; + let card_child = ChildComponent { + component: Component::Card(Card { + children: vec![inner], + timing: Default::default(), + style: card_style, + timeline: Vec::new(), + stagger: None, + }), + position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + }; + let scene = vec![card_child]; + let built = build_scene(&scene, (1920.0, 1080.0)); + let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default()); + + // The scene root is node 0 (the implicit scene root), the card is node 1, + // and the component is node 2 (first child of the card). + // Walk the tree to find node 2. + let component_node_id = { + // root → card_child (id=1) → card's inner child (the component, id=2) + built + .root + .children + .first() // card_child box node + .and_then(|card_node| card_node.children.first()) // component box node + .map(|n| n.id) + .expect("component node id must exist") + }; + layout + .get(component_node_id) + .copied() + .expect("component must have a layout entry") + } + + #[test] + fn terminal_intrinsic_height_reflects_line_count() { + // Terminal with 3 lines, default font size (14px), line-height ratio + // 22/14 ≈ 1.57 → line_height = ceil(14 * 22/14) = 22. + // Expected minimum height: chrome (36) + padding_top (16) + 3 * 22 + padding_bottom (16) = 134. + // We assert ≥ 3 * line_height_min = 3 * 22 = 66 (conservative: chrome may be excluded + // in some configs, let the intrinsic beat 0). + let json = serde_json::json!({ + "type": "terminal", + "lines": [ + { "text": "echo hello", "line_type": "command" }, + { "text": "hello", "line_type": "output" }, + { "text": "echo world", "line_type": "command" } + ] + }); + let layout = layout_for_unsized_component_in_flex_card(json); + assert!( + layout.height > 0.0, + "terminal height should be > 0, got {}", + layout.height + ); + // With chrome (36) + 2*padding (32) + 3 lines * line_height (22) ≥ 134 + let min_expected = 3.0 * 22.0; // conservative: at least 3 line-heights + assert!( + layout.height >= min_expected, + "terminal height {} should be ≥ {} (3 × line_height)", + layout.height, + min_expected + ); + assert!( + layout.width > 0.0, + "terminal width should be > 0, got {}", + layout.width + ); + } + + #[test] + fn table_intrinsic_height_reflects_row_count() { + // Table with 1 header row + 2 data rows. Default font_size = 14, row_height = 14 * 2.5 = 35. + // Total height = 3 rows * 35 = 105. + let json = serde_json::json!({ + "type": "table", + "headers": ["Name", "Value"], + "rows": [ + ["Alice", "42"], + ["Bob", "99"] + ] + }); + let layout = layout_for_unsized_component_in_flex_card(json); + assert!( + layout.height > 0.0, + "table height should be > 0, got {}", + layout.height + ); + // 3 rows (1 header + 2 data) × row_height (35) = 105 + let expected_min = 3.0 * 35.0; + assert!( + layout.height >= expected_min, + "table height {} should be ≥ {} (3 × row_height)", + layout.height, + expected_min + ); + assert!( + layout.width > 0.0, + "table width should be > 0, got {}", + layout.width + ); + } + + #[test] + fn codeblock_intrinsic_height_reflects_line_count() { + // Codeblock with 3 lines of code, default font_size = 14. + // Default padding = 16px each side. line_height = style.line_height_for(14) ≈ 14 * 1.5 = 21. + // Expected: 3 lines * line_height + pad_top + pad_bottom ≥ 3 * 14 = 42. + let json = serde_json::json!({ + "type": "codeblock", + "code": "fn main() {\n println!(\"hello\");\n}" + }); + let layout = layout_for_unsized_component_in_flex_card(json); + assert!( + layout.height > 0.0, + "codeblock height should be > 0, got {}", + layout.height + ); + let min_expected = 3.0 * 14.0; // very conservative: at least 3 × font_size + assert!( + layout.height >= min_expected, + "codeblock height {} should be ≥ {} (3 × font_size)", + layout.height, + min_expected + ); + assert!( + layout.width > 0.0, + "codeblock width should be > 0, got {}", + layout.width + ); + } }