Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/rustmotion-cli/src/commands/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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": [{
Expand All @@ -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
}]
}]
Expand Down
7 changes: 7 additions & 0 deletions crates/rustmotion-components/src/box_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
14 changes: 7 additions & 7 deletions crates/rustmotion-components/src/codeblock/dimensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion-components/src/codeblock/highlight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ pub(super) fn highlight_code(code: &str, language: &str, theme: &Theme) -> Vec<H
result
}

pub(super) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight) -> Option<Font> {
pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight) -> Option<Font> {
let font_mgr = rustmotion_core::engine::renderer::font_mgr();
let w: i32 = match weight {
FontWeight::Normal => 400,
Expand Down
4 changes: 2 additions & 2 deletions crates/rustmotion-components/src/codeblock/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
mod chrome;
mod diff;
mod dimensions;
mod highlight;
pub(crate) mod dimensions;
pub(crate) mod highlight;
mod render;
mod reveal;

Expand Down
254 changes: 254 additions & 0 deletions crates/rustmotion-components/src/intrinsic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>, Option<f32>),
_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<f32> = 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<f32>, Option<f32>),
_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<f32>, Option<f32>),
_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::*;
Expand Down
7 changes: 6 additions & 1 deletion crates/rustmotion-components/src/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions crates/rustmotion-components/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<skia_safe::Font> {
Expand Down
Loading
Loading