diff --git a/Cargo.lock b/Cargo.lock index 4afe7a208b9..cf42696a5a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,12 @@ dependencies = [ "serde", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.0" @@ -259,6 +265,7 @@ dependencies = [ "libc", "lsh", "stdext", + "taffy", "toml-span", "windows-sys", "winresource", @@ -620,6 +627,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -644,6 +660,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "taffy" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639627c87f43b9181c811f40a6296409e093a17bc761214cba3c15df74f86b99" +dependencies = [ + "arrayvec", + "serde", + "slotmap", + "smallvec", +] + [[package]] name = "tinytemplate" version = "1.2.1" diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 02becfdb908..d27db044c2c 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -21,6 +21,7 @@ debug-latency = [] [dependencies] lsh.workspace = true stdext.workspace = true +taffy = { version = "=0.14.0", default-features = false, features = ["std", "grid", "flexbox", "taffy_tree"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index ce03bbeb4cb..6e7d83664fb 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -12,17 +12,25 @@ use crate::localization::*; use crate::state::*; pub fn draw_editor(ctx: &mut Context, state: &mut State) { + ctx.block_begin("editor"); + ctx.inherit_focus(); + ctx.attr_css_style(css::Style { + display: css::Display::Flex, + flex_direction: css::FlexDirection::Column, + min_size: css::Size { width: css::length(0.0), height: css::length(0.0) }, + ..Default::default() + }); + + ctx.block_begin("search-container"); + ctx.attr_css_style(css::Style { + flex_direction: css::FlexDirection::Column, + flex_shrink: 0.0, + ..Default::default() + }); if !matches!(state.wants_search.kind, StateSearchKind::Hidden | StateSearchKind::Disabled) { draw_search(ctx, state); } - - let size = ctx.size(); - // TODO: The layout code should be able to just figure out the height on its own. - let height_reduction = match state.wants_search.kind { - StateSearchKind::Search => 4, - StateSearchKind::Replace => 5, - _ => 2, - }; + ctx.block_end(); if let Some(doc) = state.documents.active() { ctx.textarea("textarea", doc.buffer.clone()); @@ -32,7 +40,13 @@ pub fn draw_editor(ctx: &mut Context, state: &mut State) { ctx.block_end(); } - ctx.attr_intrinsic_size(Size { width: 0, height: size.height - height_reduction }); + ctx.attr_css_style(css::Style { + flex_grow: 1.0, + flex_basis: css::length(0.0), + min_size: css::Size { width: css::length(0.0), height: css::length(0.0) }, + ..Default::default() + }); + ctx.block_end(); } fn draw_search(ctx: &mut Context, state: &mut State) { @@ -354,3 +368,97 @@ fn validate_goto_point(line: &str) -> Option { } Some(Point { x: coords[0], y: coords[1] }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn outer(layout: &str, classname: &str) -> Rect { + let node = layout.split_once(&format!("classname: {classname}\r\n")).unwrap().1; + let values = node.split_once("outer: {").unwrap().1.split_once('}').unwrap().0; + let values: Vec = + values.split(", ").map(|value| value.parse().unwrap()).collect(); + Rect { left: values[0], top: values[1], right: values[2], bottom: values[3] } + } + + fn settle(tui: &mut Tui, state: &mut State) { + for _ in 0..10 { + if !tui.needs_settling() { + return; + } + crate::draw(tui, None, state); + } + assert!(!tui.needs_settling(), "Editor layout did not settle"); + } + + #[test] + fn css_editor_geometry_tracks_search_documents_and_resize() { + let _sys = edit::sys::init().unwrap(); + stdext::arena::init(32 * MEBI).unwrap(); + icu::init().unwrap(); + for lines in [None, Some(0), Some(1000)] { + let mut state = State::new().unwrap(); + if let Some(lines) = lines { + let doc = state.documents.add_untitled().unwrap(); + doc.buffer.borrow_mut().write_raw(&b"line\n".repeat(lines)); + } + let mut tui = Tui::new().unwrap(); + for width in [1, 2, 80] { + for search in [ + StateSearchKind::Hidden, + StateSearchKind::Disabled, + StateSearchKind::Search, + StateSearchKind::Replace, + ] { + for height in [24, 8, 7, 6, 5, 4, 3, 2, 1, 80, 32767] { + state.wants_search.kind = search; + crate::draw( + &mut tui, + Some(edit::input::Input::Resize(Size { width, height })), + &mut state, + ); + settle(&mut tui, &mut state); + assert_eq!(state.error_log_count, 0); + let scratch = stdext::arena::scratch_arena(None); + let layout = tui.debug_layout(&scratch); + let name = if lines.is_some() { "textarea" } else { "empty" }; + let editor = outer(&layout, name); + let search_height = match state.wants_search.kind { + StateSearchKind::Search => 2, + StateSearchKind::Replace => 3, + _ => 0, + }; + assert_eq!( + editor.height(), + (height - 2 - search_height).max(0), + "width={width} height={height} search_height={search_height}", + ); + assert!(editor.width() >= 0); + assert_eq!(outer(&layout, "statusbar").bottom, height); + } + } + } + } + } + + #[test] + fn css_editor_keeps_focus_through_small_viewports() { + let _sys = edit::sys::init().unwrap(); + stdext::arena::init(16 * MEBI).unwrap(); + let mut state = State::new().unwrap(); + state.documents.add_untitled().unwrap(); + let mut tui = Tui::new().unwrap(); + for height in [24, 1, 24] { + crate::draw( + &mut tui, + Some(edit::input::Input::Resize(Size { width: 80, height })), + &mut state, + ); + settle(&mut tui, &mut state); + } + crate::draw(&mut tui, Some(edit::input::Input::Text("typed")), &mut state); + let mut text = String::new(); + state.documents.active().unwrap().buffer.borrow_mut().save_as_string(&mut text); + assert_eq!(text, if cfg!(windows) { "typed" } else { "typed\n" }); + } +} diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index e9728c52fdd..4e825408d26 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -341,6 +341,12 @@ fn print_version() { fn draw(tui: &mut Tui, input: Option, state: &mut State) { let ctx = &mut tui.create_context(input); + ctx.attr_css_style(css::Style { + display: css::Display::Grid, + grid_template_columns: vec![css::flex(1.0)], + grid_template_rows: vec![css::auto(), css::flex(1.0), css::auto()], + ..Default::default() + }); draw_menubar(ctx, state); draw_editor(ctx, state); diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 37c8eb9d7b1..1177dbd3b29 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -162,6 +162,9 @@ use crate::input::{InputKeyMod, kbmod, vk}; use crate::oklab::StraightRgba; use crate::{input, simd, unicode}; +mod css_layout; +pub use taffy::prelude as css; + const ROOT_ID: u64 = 0x14057B7EF767814F; // Knuth's MMIX constant const SHIFT_TAB: InputKey = vk::TAB.with_modifiers(kbmod::SHIFT); const KBMOD_FOR_WORD_NAV: InputKeyMod = @@ -725,6 +728,7 @@ impl Tui { input_consumed, tree, + css_styles: Vec::new(), last_modal: None, focused_node: None, next_block_id_mixin: 0, @@ -792,7 +796,10 @@ impl Tui { for root in Tree::iterate_siblings(Some(self.prev_tree.root_first)) { let mut root = root.borrow_mut(); - root.compute_intrinsic_size(unsafe { mem::transmute(&self.arena_next) }); + root.compute_intrinsic_size( + unsafe { mem::transmute(&self.arena_next) }, + &ctx.css_styles, + ); } let viewport = self.size.as_rect(); @@ -830,7 +837,7 @@ impl Tui { root.inner_clipped = root.inner; let outer = root.outer; - root.layout_children(outer); + root.layout_children(outer, &ctx.css_styles); } } @@ -1391,6 +1398,8 @@ pub struct Context<'a, 'input> { input_consumed: bool, tree: Tree<'a>, + // Styles own heap allocations; unlike Tree and Node, Context is dropped normally. + css_styles: Vec, last_modal: Option<&'a NodeCell<'a>>, focused_node: Option<&'a NodeCell<'a>>, next_block_id_mixin: u64, @@ -1656,6 +1665,26 @@ impl<'a> Context<'a, '_> { last_node.intrinsic_size_set = true; } + /// Opts this node into CSS Grid or Flexbox sizing, in terminal cells. + /// + /// Styled blocks lay out their children with Taffy. Other widgets remain opaque, + /// intrinsically measured boxes with their existing rendering and internal layout. + /// Labels do not wrap; textareas keep their own scrolling/wrapping behavior. + /// Use `attr_border` for borders: CSS border widths are reserved for legacy + /// padding, borders and scrollbar space. CSS padding and margins are supported. + /// This is a geometry API, not a CSS parser or browser renderer. + pub fn attr_css_style(&mut self, style: css::Style) { + assert!(matches!(style.display, css::Display::Grid | css::Display::Flex)); + assert_eq!(style.border, css::Rect::zero(), "Use attr_border, not CSS border widths"); + let mut node = self.tree.last_node.borrow_mut(); + if let Some(index) = node.css_style { + self.css_styles[index] = style; + } else { + node.css_style = Some(self.css_styles.len()); + self.css_styles.push(style); + } + } + /// Turns the current node into a floating node, /// like a popup, modal or a tooltip. pub fn attr_float(&mut self, spec: FloatSpec) { @@ -3869,6 +3898,7 @@ struct Node<'a> { intrinsic_size: Size, intrinsic_size_set: bool, + css_style: Option, outer: Rect, // in screen-space, calculated during layout inner: Rect, // in screen-space, calculated during layout outer_clipped: Rect, // in screen-space, calculated during layout, restricted to the viewport @@ -3888,6 +3918,8 @@ impl<'a> Node<'a> { outer.top += self.attributes.padding.top + t as CoordType; outer.right -= self.attributes.padding.right + r as CoordType; outer.bottom -= self.attributes.padding.bottom + b as CoordType; + outer.right = outer.right.max(outer.left); + outer.bottom = outer.bottom.max(outer.top); outer } @@ -3912,7 +3944,7 @@ impl<'a> Node<'a> { } /// Computes the intrinsic size of this node and its children. - fn compute_intrinsic_size(&mut self, arena: &'a Arena) { + fn compute_intrinsic_size(&mut self, arena: &'a Arena, styles: &[css::Style]) { match &mut self.content { NodeContent::Table(spec) => { // Calculate each row's height and the maximum width of each of its columns. @@ -3922,7 +3954,7 @@ impl<'a> Node<'a> { for (column, cell) in Tree::iterate_siblings(row.children.first).enumerate() { let mut cell = cell.borrow_mut(); - cell.compute_intrinsic_size(arena); + cell.compute_intrinsic_size(arena, styles); let size = cell.intrinsic_to_outer(); @@ -3982,16 +4014,18 @@ impl<'a> Node<'a> { for child in Tree::iterate_siblings(self.children.first) { let mut child = child.borrow_mut(); - child.compute_intrinsic_size(arena); + child.compute_intrinsic_size(arena, styles); let size = child.intrinsic_to_outer(); max_width = max_width.max(size.width); total_height += size.height; } - if !self.intrinsic_size_set { - self.intrinsic_size.width = max_width; - self.intrinsic_size.height = total_height; + self.intrinsic_size = if css_layout::is_container(self) { + css_layout::measure(self, styles) + } else { + Size { width: max_width, height: total_height } + }; self.intrinsic_size_set = true; } } @@ -4000,7 +4034,11 @@ impl<'a> Node<'a> { /// Lays out the children of this node. /// The clip rect restricts "rendering" to a certain area (the viewport). - fn layout_children(&mut self, clip: Rect) { + fn layout_children(&mut self, clip: Rect, styles: &[css::Style]) { + if css_layout::is_container(self) { + css_layout::layout(self, clip, styles); + return; + } if self.children.first.is_none() || self.inner.is_empty() { return; } @@ -4042,7 +4080,7 @@ impl<'a> Node<'a> { x += size.width + spec.cell_gap.width; row_height = row_height.max(size.height); - cell.layout_children(clip); + cell.layout_children(clip, styles); } x = self.inner.left; @@ -4074,7 +4112,7 @@ impl<'a> Node<'a> { content.inner_clipped = content.inner.intersect(self.inner_clipped); let clip = content.inner_clipped; - content.layout_children(clip); + content.layout_children(clip, styles); } _ => { let width = self.inner.right - self.inner.left; @@ -4109,7 +4147,7 @@ impl<'a> Node<'a> { for child in Tree::iterate_siblings(self.children.first) { let mut child = child.borrow_mut(); - child.layout_children(clip); + child.layout_children(clip, styles); } } } diff --git a/crates/edit/src/tui/css_layout.rs b/crates/edit/src/tui/css_layout.rs new file mode 100644 index 00000000000..bf1c591e3cc --- /dev/null +++ b/crates/edit/src/tui/css_layout.rs @@ -0,0 +1,482 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A frame-local Taffy tree. Arena nodes retain only style indices, never owners. + +use super::{CoordType, Node, NodeCell, NodeContent, Point, Rect, Size, Tree, css}; + +pub(super) fn is_container(node: &Node<'_>) -> bool { + node.css_style.is_some() && matches!(node.content, NodeContent::None) +} + +struct Entry<'a> { + node: &'a NodeCell<'a>, + id: css::NodeId, + parent: css::NodeId, + container: bool, +} + +struct Layout<'a> { + tree: taffy::TaffyTree, + entries: Vec>, +} + +impl<'a> Layout<'a> { + fn new() -> Self { + Self { tree: taffy::TaffyTree::new(), entries: Vec::new() } + } + + fn insert(&mut self, node: &Node<'a>, styles: &[css::Style]) -> css::NodeId { + let mut style = node.css_style.map(|index| styles[index].clone()).unwrap_or_default(); + // Taffy owns the box model. Reserve its border for all legacy insets so + // those insets participate in sizing exactly once, including on leaves. + let padding = node.attributes.padding; + let border = node.attributes.bordered as CoordType; + style.border = css::Rect { + left: css::length((padding.left + border) as f32), + right: css::length( + (padding.right + + border.max(matches!(node.content, NodeContent::Scrollarea(..)) as CoordType)) + as f32, + ), + top: css::length((padding.top + border) as f32), + bottom: css::length((padding.bottom + border) as f32), + }; + if !is_container(node) { + return self + .tree + .new_leaf_with_context(style, node.intrinsic_size) + .expect("New CSS leaf"); + } + + let id = self.tree.new_with_children(style, &[]).expect("New CSS container"); + for child in Tree::iterate_siblings(node.children.first) { + let child_node = child.borrow(); + let child_id = self.insert(&child_node, styles); + self.tree.add_child(id, child_id).expect("Fresh CSS parent and child"); + self.entries.push(Entry { + node: child, + id: child_id, + parent: id, + container: is_container(&child_node), + }); + } + id + } + + fn compute(&mut self, root: css::NodeId, available: css::Size) { + self.tree + .compute_layout_with_measure(root, available, |inputs, _, context, style| { + // Legacy widgets supply current-frame intrinsic metrics, not CSS + // text reflow. Labels are single-line; textareas own their wrapping + // and scrolling. Only definite dimensions override these metrics. + let intrinsic = context.copied().unwrap_or_default(); + taffy::compute_leaf_layout( + inputs, + style, + |_, _| 0.0, + |known, _| css::Size { + width: known.width.unwrap_or(intrinsic.width.max(0) as f32), + height: known.height.unwrap_or(intrinsic.height.max(0) as f32), + }, + ) + }) + .expect("CSS tree contains only live, frame-local node IDs"); + } +} + +pub(super) fn measure(node: &Node<'_>, styles: &[css::Style]) -> Size { + let mut layout = Layout::new(); + let root = layout.insert(node, styles); + layout.compute( + root, + css::Size { + width: css::AvailableSpace::MaxContent, + height: css::AvailableSpace::MaxContent, + }, + ); + let result = layout.tree.layout(root).expect("Measured CSS root"); + let insets = node.intrinsic_to_outer(); + Size { + width: (result.size.width as CoordType - insets.width + node.intrinsic_size.width).max(0), + height: (result.size.height as CoordType - insets.height + node.intrinsic_size.height) + .max(0), + } +} + +fn apply(node: &mut Node<'_>, result: &taffy::Layout, origin: Point, clip: Rect) { + node.outer = Rect { + left: origin.x, + top: origin.y, + right: origin.x + result.size.width.max(0.0) as CoordType, + bottom: origin.y + result.size.height.max(0.0) as CoordType, + }; + // Legacy widgets use outer for hit-testing and viewport sizing, not only + // painting. Preserve Taffy's overflowing origins separately during traversal. + node.outer.left = node.outer.left.clamp(clip.left, clip.right); + node.outer.right = node.outer.right.clamp(clip.left, clip.right); + node.outer.top = node.outer.top.clamp(clip.top, clip.bottom); + node.outer.bottom = node.outer.bottom.clamp(clip.top, clip.bottom); + node.inner = Rect { + left: node.outer.left + (result.border.left + result.padding.left) as CoordType, + top: node.outer.top + (result.border.top + result.padding.top) as CoordType, + right: node.outer.right - (result.border.right + result.padding.right) as CoordType, + bottom: node.outer.bottom - (result.border.bottom + result.padding.bottom) as CoordType, + }; + node.inner.left = node.inner.left.clamp(node.outer.left, node.outer.right); + node.inner.right = node.inner.right.clamp(node.inner.left, node.outer.right); + node.inner.top = node.inner.top.clamp(node.outer.top, node.outer.bottom); + node.inner.bottom = node.inner.bottom.clamp(node.inner.top, node.outer.bottom); + node.outer_clipped = node.outer.intersect(clip); + node.inner_clipped = node.inner.intersect(clip); +} + +pub(super) fn layout(node: &mut Node<'_>, clip: Rect, styles: &[css::Style]) { + let mut layout = Layout::new(); + let root = layout.insert(node, styles); + let mut style = layout.tree.style(root).expect("CSS root style").clone(); + let width = (node.outer.right - node.outer.left).max(0) as f32; + let height = (node.outer.bottom - node.outer.top).max(0) as f32; + // The enclosing legacy layout (or terminal viewport) has already allotted + // this border box. Nested CSS containers are solved in this same Taffy tree. + style.box_sizing = css::BoxSizing::BorderBox; + style.size = css::Size { width: css::length(width), height: css::length(height) }; + style.min_size = css::Size { width: css::length(0.0), height: css::length(0.0) }; + style.max_size = css::Size { width: css::length(width), height: css::length(height) }; + layout.tree.set_style(root, style).expect("CSS root style"); + layout.compute( + root, + css::Size { + width: css::AvailableSpace::Definite(width), + height: css::AvailableSpace::Definite(height), + }, + ); + + let origin = Point { x: node.outer.left, y: node.outer.top }; + apply(node, layout.tree.layout(root).expect("CSS root layout"), origin, clip); + // Entries are post-order; reverse traversal guarantees parents are placed + // before children. Keep origins separate from clipped painting rectangles. + let mut origins = std::collections::HashMap::new(); + origins.insert(root, (origin, node.inner_clipped)); + for entry in layout.entries.iter().rev() { + let (parent_origin, parent_clip) = origins[&entry.parent]; + let result = layout.tree.layout(entry.id).expect("CSS child layout"); + let origin = Point { + x: parent_origin.x + result.location.x as CoordType, + y: parent_origin.y + result.location.y as CoordType, + }; + let mut child = entry.node.borrow_mut(); + apply(&mut child, result, origin, parent_clip); + origins.insert(entry.id, (origin, child.inner_clipped)); + if !entry.container { + let clip = child.inner_clipped; + child.layout_children(clip, styles); + } + } +} + +#[cfg(test)] +mod tests { + use stdext::arena::Arena; + + use super::*; + + fn child<'a>( + arena: &'a Arena, + parent: &'a NodeCell<'a>, + style: Option, + width: CoordType, + height: CoordType, + ) -> &'a NodeCell<'a> { + let child = Tree::alloc_node(arena); + let mut parent_node = parent.borrow_mut(); + { + let mut node = child.borrow_mut(); + node.parent = Some(parent); + node.css_style = style; + node.intrinsic_size = Size { width, height }; + node.intrinsic_size_set = true; + node.siblings.prev = parent_node.children.last; + } + if let Some(last) = parent_node.children.last { + last.borrow_mut().siblings.next = Some(child); + } else { + parent_node.children.first = Some(child); + } + parent_node.children.last = Some(child); + parent_node.child_count += 1; + child + } + + fn root(arena: &Arena) -> &NodeCell<'_> { + let root = Tree::alloc_node(arena); + root.borrow_mut().css_style = Some(0); + root + } + + fn place<'a>(arena: &'a Arena, root: &'a NodeCell<'a>, styles: &[css::Style], size: Size) { + let mut node = root.borrow_mut(); + node.compute_intrinsic_size(arena, styles); + node.outer = size.as_rect(); + node.inner = node.outer_to_inner(node.outer); + node.outer_clipped = node.outer; + node.inner_clipped = node.inner; + node.layout_children(size.as_rect(), styles); + } + + fn grid() -> css::Style { + css::Style { + display: css::Display::Grid, + grid_template_columns: vec![css::flex(1.0)], + ..Default::default() + } + } + + #[test] + fn nested_grid_and_flex_fill_remaining_rows() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let header = child(&arena, root, None, 20, 1); + let editor = child(&arena, root, Some(1), 0, 0); + let text = child(&arena, editor, Some(2), 0, 0); + let search = child(&arena, editor, Some(3), 0, 0); + child(&arena, search, None, 10, 2); + let footer = child(&arena, root, None, 20, 1); + let styles = [ + css::Style { + grid_template_rows: vec![css::auto(), css::flex(1.0), css::auto()], + ..grid() + }, + css::Style { + flex_direction: css::FlexDirection::Column, + min_size: css::Size { width: css::length(0.0), height: css::length(0.0) }, + ..Default::default() + }, + css::Style { + flex_grow: 1.0, + flex_basis: css::length(0.0), + min_size: css::Size { width: css::length(0.0), height: css::length(0.0) }, + ..Default::default() + }, + css::Style { flex_shrink: 0.0, ..Default::default() }, + ]; + place(&arena, root, &styles, Size { width: 20, height: 12 }); + assert_eq!(header.borrow().outer, Rect { left: 0, top: 0, right: 20, bottom: 1 }); + assert_eq!(editor.borrow().outer, Rect { left: 0, top: 1, right: 20, bottom: 11 }); + assert_eq!(text.borrow().outer, Rect { left: 0, top: 1, right: 20, bottom: 9 }); + assert_eq!(search.borrow().outer, Rect { left: 0, top: 9, right: 20, bottom: 11 }); + assert_eq!(footer.borrow().outer, Rect { left: 0, top: 11, right: 20, bottom: 12 }); + } + + #[test] + fn fractional_tracks_round_shared_edges_without_gaps() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let first = child(&arena, root, None, 0, 1); + let second = child(&arena, root, None, 0, 1); + let third = child(&arena, root, None, 0, 1); + let styles = [css::Style { + grid_template_columns: vec![css::flex(1.0), css::flex(1.0), css::flex(1.0)], + ..grid() + }]; + place(&arena, root, &styles, Size { width: 10, height: 1 }); + assert_eq!(first.borrow().outer.right, 3); + assert_eq!(second.borrow().outer.left, 3); + assert_eq!(second.borrow().outer.right, 7); + assert_eq!(third.borrow().outer.left, 7); + assert_eq!(third.borrow().outer.right, 10); + } + + #[test] + fn fixed_auto_and_fractional_rows_use_intrinsic_measurement() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let fixed = child(&arena, root, None, 0, 0); + let auto = child(&arena, root, None, 0, 3); + let flexible = child(&arena, root, None, 0, 0); + let styles = [css::Style { + grid_template_rows: vec![css::length(2.0), css::auto(), css::flex(1.0)], + ..grid() + }]; + place(&arena, root, &styles, Size { width: 10, height: 11 }); + assert_eq!(fixed.borrow().outer.bottom, 2); + assert_eq!(auto.borrow().outer.bottom, 5); + assert_eq!(flexible.borrow().outer.bottom, 11); + } + + #[test] + fn flex_grow_and_shrink_are_engine_sized() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let first = child(&arena, root, Some(1), 0, 0); + let second = child(&arena, root, Some(2), 0, 0); + let mut styles = [ + css::Style::default(), + css::Style { + flex_basis: css::length(2.0), + flex_grow: 1.0, + min_size: css::Size { width: css::length(0.0), height: css::length(0.0) }, + ..Default::default() + }, + css::Style { + flex_basis: css::length(2.0), + flex_grow: 3.0, + min_size: css::Size { width: css::length(0.0), height: css::length(0.0) }, + ..Default::default() + }, + ]; + place(&arena, root, &styles, Size { width: 12, height: 1 }); + assert_eq!(first.borrow().outer.right, 4); + assert_eq!(second.borrow().outer.left, 4); + assert_eq!(second.borrow().outer.right, 12); + styles[1].flex_basis = css::length(8.0); + styles[2].flex_basis = css::length(8.0); + place(&arena, root, &styles, Size { width: 10, height: 1 }); + assert_eq!(first.borrow().outer.right, 5); + assert_eq!(second.borrow().outer.left, 5); + assert_eq!(second.borrow().outer.right, 10); + } + + #[test] + fn bare_fraction_keeps_auto_minimum_but_flex_track_can_shrink() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let item = child(&arena, root, None, 20, 1); + let mut styles = [css::Style { grid_template_columns: vec![css::fr(1.0)], ..grid() }]; + let mut engine = Layout::new(); + let engine_root = engine.insert(&root.borrow(), &styles); + engine.compute( + engine_root, + css::Size { + width: css::AvailableSpace::Definite(5.0), + height: css::AvailableSpace::Definite(1.0), + }, + ); + assert_eq!(engine.tree.layout(engine.entries[0].id).unwrap().size.width, 20.0); + place(&arena, root, &styles, Size { width: 5, height: 1 }); + assert_eq!(item.borrow().outer.right, 5); + assert_eq!(item.borrow().outer_clipped.right, 5); + styles[0].grid_template_columns = vec![css::flex(1.0)]; + place(&arena, root, &styles, Size { width: 5, height: 1 }); + assert_eq!(item.borrow().outer.right, 5); + } + + #[test] + fn legacy_insets_count_once_and_tiny_viewports_stay_nonnegative() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let item = child(&arena, root, None, 3, 1); + item.borrow_mut().attributes.bordered = true; + item.borrow_mut().attributes.padding = Rect::two(1, 1); + let styles = [css::Style { grid_template_rows: vec![css::auto()], ..grid() }]; + place(&arena, root, &styles, Size { width: 7, height: 5 }); + assert_eq!(item.borrow().inner, Rect { left: 2, top: 2, right: 5, bottom: 3 }); + for height in 0..3 { + place(&arena, root, &styles, Size { width: 0, height }); + for cell in [root, item] { + let node = cell.borrow(); + for rect in [node.outer, node.inner, node.outer_clipped, node.inner_clipped] { + assert!(rect.right >= rect.left && rect.bottom >= rect.top); + assert!(rect.right <= 0 && rect.bottom <= height); + } + } + } + } + + #[test] + fn unmarked_blocks_keep_legacy_vertical_layout() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = Tree::alloc_node(&arena); + let first = child(&arena, root, None, 3, 2); + let second = child(&arena, root, None, 4, 3); + place(&arena, root, &[], Size { width: 10, height: 10 }); + assert_eq!(first.borrow().outer, Rect { left: 0, top: 0, right: 10, bottom: 2 }); + assert_eq!(second.borrow().outer, Rect { left: 0, top: 2, right: 10, bottom: 5 }); + } + + #[test] + fn css_row_intrinsic_height_survives_inside_a_legacy_block() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = Tree::alloc_node(&arena); + let row = child(&arena, root, Some(0), 0, 0); + row.borrow_mut().intrinsic_size_set = false; + child(&arena, row, None, 3, 1); + child(&arena, row, None, 4, 1); + let footer = child(&arena, root, None, 10, 1); + place(&arena, root, &[css::Style::default()], Size { width: 10, height: 10 }); + assert_eq!(row.borrow().intrinsic_size, Size { width: 7, height: 1 }); + assert_eq!(row.borrow().outer.height(), 1); + assert_eq!(footer.borrow().outer.top, 1); + } + + #[test] + fn opaque_table_retains_legacy_columns_inside_css_padding() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let table = child(&arena, root, Some(1), 0, 0); + { + let mut node = table.borrow_mut(); + node.intrinsic_size_set = false; + node.content = NodeContent::Table(super::super::TableContent { + columns: stdext::collections::BVec::empty(), + cell_gap: Size::default(), + }); + } + let row = child(&arena, table, None, 0, 0); + let first = child(&arena, row, None, 2, 1); + let second = child(&arena, row, None, 3, 1); + let styles = [ + css::Style { grid_template_rows: vec![css::auto()], ..grid() }, + css::Style { padding: css::Rect::length(1.0), ..Default::default() }, + ]; + place(&arena, root, &styles, Size { width: 7, height: 3 }); + assert_eq!(table.borrow().inner, Rect { left: 1, top: 1, right: 6, bottom: 2 }); + assert_eq!(first.borrow().outer, Rect { left: 1, top: 1, right: 3, bottom: 2 }); + assert_eq!(second.borrow().outer, Rect { left: 3, top: 1, right: 6, bottom: 2 }); + } + + #[test] + fn narrow_label_stays_one_line_and_scrollarea_keeps_its_scroll_offset() { + let arena = Arena::new(1024 * 1024).unwrap(); + let root = root(&arena); + let label = child(&arena, root, None, 20, 1); + label.borrow_mut().content = NodeContent::Text(super::super::TextContent { + text: stdext::collections::BString::empty(), + chunks: stdext::collections::BVec::empty(), + overflow: super::super::Overflow::Clip, + }); + let scrollarea = child(&arena, root, None, 0, 10); + scrollarea.borrow_mut().content = + NodeContent::Scrollarea(super::super::ScrollareaContent { + scroll_offset: Point { x: 0, y: 2 }, + scroll_offset_y_drag_start: CoordType::MIN, + thumb_height: 0, + }); + let content = child(&arena, scrollarea, None, 5, 10); + let styles = + [css::Style { grid_template_rows: vec![css::auto(), css::flex(1.0)], ..grid() }]; + place(&arena, root, &styles, Size { width: 5, height: 4 }); + assert_eq!(label.borrow().outer, Rect { left: 0, top: 0, right: 5, bottom: 1 }); + assert_eq!(scrollarea.borrow().inner, Rect { left: 0, top: 1, right: 4, bottom: 4 }); + assert_eq!(content.borrow().outer, Rect { left: 0, top: -1, right: 4, bottom: 9 }); + assert_eq!(content.borrow().outer_clipped, scrollarea.borrow().inner); + } + + #[test] + fn floated_nodes_are_excluded_and_arena_types_do_not_own_styles() { + assert!(!std::mem::needs_drop::>()); + assert!(!std::mem::needs_drop::>()); + let arena = Arena::new(1024 * 1024).unwrap(); + let mut tree = Tree::new(&arena); + tree.root_first.borrow_mut().css_style = Some(0); + let float = Tree::alloc_node(&arena); + tree.push_child(float); + tree.move_node_to_root(float, Some(tree.root_first)); + let item = child(&arena, tree.root_first, None, 2, 1); + float.borrow_mut().intrinsic_size = Size { width: 100, height: 100 }; + float.borrow_mut().intrinsic_size_set = true; + place(&arena, tree.root_first, &[grid()], Size { width: 8, height: 4 }); + assert_eq!(item.borrow().outer, Rect { left: 0, top: 0, right: 8, bottom: 4 }); + assert_eq!(float.borrow().outer, Rect::default()); + } +}