diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index ce03bbeb4cb..65170ef5ae2 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -12,17 +12,16 @@ 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_display(Display::Grid); + ctx.attr_grid_template_columns(&[GridTrack::Fraction(1)]); + ctx.attr_grid_template_rows(&[GridTrack::Auto, GridTrack::Fraction(1)]); + ctx.block_begin("search-container"); 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 +31,7 @@ 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.block_end(); } fn draw_search(ctx: &mut Context, state: &mut State) { diff --git a/crates/edit/src/bin/edit/layout_tests.rs b/crates/edit/src/bin/edit/layout_tests.rs new file mode 100644 index 00000000000..5f999fd2e46 --- /dev/null +++ b/crates/edit/src/bin/edit/layout_tests.rs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use edit::icu; +use edit::input::Input; + +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(); + let [left, top, right, bottom]: [CoordType; 4] = values.try_into().unwrap(); + Rect { left, top, right, bottom } +} + +fn editor(lines: Option) -> (sys::Deinit, Tui, State) { + let sys = sys::init().unwrap(); + arena::init(32 * MEBI).unwrap(); + icu::init().unwrap(); + let mut state = State::new().unwrap(); + if let Some(lines) = lines { + let doc = state.documents.add_untitled().unwrap(); + let mut buffer = doc.buffer.borrow_mut(); + buffer.set_crlf(false); + buffer.write_raw(&b"line\n".repeat(lines)); + } + (sys, Tui::new().unwrap(), state) +} + +fn frame(tui: &mut Tui, state: &mut State, input: Option>) { + crate::draw(tui, input, state); + for _ in 0..10 { + if !tui.needs_settling() { + break; + } + crate::draw(tui, None, state); + } + assert!(!tui.needs_settling(), "Editor layout did not settle"); + assert_eq!(state.error_log_count, 0); +} + +fn resize(tui: &mut Tui, state: &mut State, width: CoordType, height: CoordType) { + frame(tui, state, Some(Input::Resize(Size { width, height }))); +} + +#[test] +fn grid_editor_geometry_tracks_search_documents_and_resize() { + use StateSearchKind::{Disabled, Hidden, Replace, Search}; + + for lines in [None, Some(0), Some(1000)] { + let (_sys, mut tui, mut state) = editor(lines); + for width in [1, 2, 80] { + for search in [Hidden, Disabled, Search, Replace] { + for height in [24, 8, 7, 6, 5, 4, 3, 2, 1, 80, 32767, 24] { + state.wants_search.kind = search; + resize(&mut tui, &mut state, width, height); + let scratch = 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 { + Search => 2, + Replace => 3, + _ => 0, + }; + let expected = (height - 2 - search_height).max(0); + assert_eq!(editor.height(), expected, "{width}x{height}/{search_height}"); + assert!(editor.width() >= 0); + assert_eq!(outer(&layout, "statusbar").bottom, height); + } + } + } + } +} + +#[test] +fn grid_editor_preserves_focus_through_resize_search_and_menus() { + for lines in [0, 1000] { + let (_sys, mut tui, mut state) = editor(Some(lines)); + for height in [24, 1, 24, 3, 24] { + resize(&mut tui, &mut state, 80, height); + } + if lines > 0 { + frame(&mut tui, &mut state, Some(Input::Keyboard(kbmod::CTRL | vk::HOME))); + frame(&mut tui, &mut state, Some(Input::Keyboard(vk::NEXT))); + let doc = state.documents.active().unwrap(); + assert_eq!(doc.buffer.borrow().cursor_logical_pos().y, 21); + frame(&mut tui, &mut state, Some(Input::Keyboard(kbmod::CTRL | vk::END))); + } + let mut expected = "line\n".repeat(lines); + for phase in ["resize", "search", "menu"] { + if phase == "search" { + state.wants_search.kind = StateSearchKind::Search; + state.wants_search.focus = true; + frame(&mut tui, &mut state, None); + frame(&mut tui, &mut state, Some(Input::Text("needle"))); + assert_eq!(state.search_needle, "needle"); + frame(&mut tui, &mut state, Some(Input::Keyboard(vk::ESCAPE))); + assert!(state.wants_search.kind == StateSearchKind::Hidden); + } + if phase == "menu" { + frame(&mut tui, &mut state, Some(Input::Keyboard(vk::F10))); + { + let scratch = arena::scratch_arena(None); + let layout = tui.debug_layout(&scratch); + assert!(layout.contains("classname: flyout\r\n")); + assert_eq!(outer(&layout, "textarea").height(), 22); + assert_eq!(outer(&layout, "statusbar").bottom, 24); + } + frame(&mut tui, &mut state, Some(Input::Keyboard(vk::ESCAPE))); + } + frame(&mut tui, &mut state, Some(Input::Text(phase))); + expected.push_str(phase); + let mut text = String::new(); + state.documents.active().unwrap().buffer.borrow_mut().save_as_string(&mut text); + let ending = if cfg!(windows) { "" } else { "\n" }; + assert_eq!(text, format!("{expected}{ending}")); + let scratch = arena::scratch_arena(None); + assert!(tui.render(&scratch).contains(phase), "Typed text must be visible"); + } + } +} diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index e9728c52fdd..6eaa4ae7e37 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -7,6 +7,8 @@ mod draw_editor; mod draw_filepicker; mod draw_menubar; mod draw_statusbar; +#[cfg(test)] +mod layout_tests; mod localization; mod settings; mod state; @@ -341,6 +343,9 @@ fn print_version() { fn draw(tui: &mut Tui, input: Option, state: &mut State) { let ctx = &mut tui.create_context(input); + ctx.attr_display(Display::Grid); + ctx.attr_grid_template_columns(&[GridTrack::Fraction(1)]); + ctx.attr_grid_template_rows(&[GridTrack::Auto, GridTrack::Fraction(1), GridTrack::Auto]); draw_menubar(ctx, state); draw_editor(ctx, state); diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 53f0233d555..800b59cb924 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}; +#[cfg(test)] +mod grid_tests; + 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 = @@ -246,6 +249,23 @@ pub enum Position { Right, } +#[derive(Clone, Copy)] +pub enum Display { + Block, + Grid, +} + +/// Grid tracks use terminal cells; overflowing content is clipped. +#[derive(Default, Clone, Copy, Debug)] +pub enum GridTrack { + #[default] + Auto, + /// An exact cell count; negative sizes become zero. + Fixed(CoordType), + /// A fractional track with a zero minimum: `minmax(0, Nfr)`. + Fraction(u16), +} + /// Controls the text overflow behavior of a label /// when the text doesn't fit the container. #[derive(Default, Clone, Copy, PartialEq, Eq)] @@ -1698,6 +1718,37 @@ impl<'a> Context<'a, '_> { last_node.attributes.position = align; } + /// Selects block or grid layout on a block, including the implicit viewport root. + pub fn attr_display(&mut self, display: Display) { + let content = &mut self.tree.last_node.borrow_mut().content; + match (&*content, display) { + (NodeContent::None, Display::Grid) => *content = NodeContent::Grid(Default::default()), + (NodeContent::Grid(_), Display::Block) => *content = NodeContent::None, + (NodeContent::None, Display::Block) | (NodeContent::Grid(_), Display::Grid) => {} + _ => debug_assert!(false, "display requires a block"), + } + } + + /// Sets columns on a grid. Children occupy cells in row-major order, excluding floats. + /// An empty template creates one Auto column; additional rows are Auto. + pub fn attr_grid_template_columns(&mut self, columns: &[GridTrack]) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.columns = GridContent::tracks(self.arena(), columns); + } else { + debug_assert!(false, "grid-template-columns requires display: grid"); + } + } + + /// Sets rows on a grid. Items stretch vertically; Auto tracks retain intrinsic + /// sizes and share unused space when there are no positive fractional tracks. + pub fn attr_grid_template_rows(&mut self, rows: &[GridTrack]) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.rows = GridContent::tracks(self.arena(), rows); + } else { + debug_assert!(false, "grid-template-rows requires display: grid"); + } + } + /// Assigns padding to the current node. pub fn attr_padding(&mut self, padding: Rect) { let mut last_node = self.tree.last_node.borrow_mut(); @@ -3756,6 +3807,143 @@ struct TableContent<'a> { cell_gap: Size, } +#[derive(Default)] +struct GridTrackSize { + template: GridTrack, + intrinsic: CoordType, + start: CoordType, + end: CoordType, +} + +#[derive(Default)] +struct GridContent<'a> { + columns: BVec<'a, GridTrackSize>, + rows: BVec<'a, GridTrackSize>, +} + +impl<'a> GridContent<'a> { + fn tracks(arena: &'a Arena, template: &[GridTrack]) -> BVec<'a, GridTrackSize> { + let mut tracks = BVec::empty(); + for &template in template { + tracks.push(arena, GridTrackSize { template, ..Default::default() }); + } + tracks + } + + fn measure(&mut self, first: Option<&'a NodeCell<'a>>, arena: &'a Arena) -> Size { + if self.columns.is_empty() { + self.columns.push(arena, GridTrackSize::default()); + } + for (index, child) in Tree::iterate_siblings(first).enumerate() { + let mut child = child.borrow_mut(); + child.compute_intrinsic_size(arena); + let size = child.intrinsic_to_outer(); + let row = index / self.columns.len(); + if row == self.rows.len() { + self.rows.push(arena, GridTrackSize::default()); + } + let column = index % self.columns.len(); + self.columns[column].intrinsic = self.columns[column].intrinsic.max(size.width); + self.rows[row].intrinsic = self.rows[row].intrinsic.max(size.height); + } + Size { + width: Self::preferred_size(&self.columns), + height: Self::preferred_size(&self.rows), + } + } + + fn preferred_size(tracks: &[GridTrackSize]) -> CoordType { + let mut size: CoordType = 0; + let mut unit = 0; + let mut weights: CoordType = 0; + for track in tracks { + match track.template { + GridTrack::Auto => size = size.saturating_add(track.intrinsic), + GridTrack::Fixed(fixed) => size = size.saturating_add(fixed.max(0)), + GridTrack::Fraction(0) => {} + GridTrack::Fraction(weight) => { + let weight = weight as CoordType; + // Round upward so each fractional track can fit its intrinsic content. + unit = unit.max( + track.intrinsic / weight + CoordType::from(track.intrinsic % weight != 0), + ); + weights = weights.saturating_add(weight); + } + } + } + size.saturating_add(unit.saturating_mul(weights)) + } + + fn allocate(tracks: &mut [GridTrackSize], available: CoordType) { + let mut remaining = available.max(0); + let mut weights = 0u128; + let mut automatic = 0u128; + for track in tracks.iter_mut() { + track.end = match track.template { + GridTrack::Auto => { + automatic += 1; + track.intrinsic + } + GridTrack::Fixed(size) => size.max(0), + GridTrack::Fraction(weight) => { + weights += u128::from(weight); + 0 + } + }; + remaining = remaining.saturating_sub(track.end).max(0); + } + let stretch_auto = weights == 0; + let total = if stretch_auto { automatic } else { weights }; + let mut prefix = 0; + let mut assigned = 0; + let mut offset: CoordType = 0; + for track in tracks { + prefix += match track.template { + GridTrack::Auto if stretch_auto => 1, + GridTrack::Fraction(weight) => u128::from(weight), + _ => 0, + }; + let share = (remaining as u128 * prefix).checked_div(total).unwrap_or(0) as CoordType; + track.start = offset; + track.end = offset.saturating_add(track.end).saturating_add(share - assigned); + offset = track.end; + assigned = share; + } + } + + fn layout(&mut self, first: Option<&'a NodeCell<'a>>, inner: Rect, clip: Rect) { + Self::allocate(&mut self.columns, inner.width()); + Self::allocate(&mut self.rows, inner.height()); + for (index, child) in Tree::iterate_siblings(first).enumerate() { + let column = &self.columns[index % self.columns.len()]; + let row = &self.rows[index / self.columns.len()]; + let rect = Rect { + left: inner.left.saturating_add(column.start), + top: inner.top.saturating_add(row.start), + right: inner.left.saturating_add(column.end), + bottom: inner.top.saturating_add(row.end), + }; + Self::place_item(&mut child.borrow_mut(), rect, inner, clip); + } + } + + fn place_item(cell: &mut Node<'a>, mut rect: Rect, bounds: Rect, clip: Rect) { + let available = rect.width(); + let width = cell.intrinsic_to_outer().width.min(available); + let remaining = available - width; + let (offset, width) = match cell.attributes.position { + Position::Stretch => (0, available), + Position::Left => (0, width), + Position::Center => (remaining / 2, width), + Position::Right => (remaining, width), + }; + rect.left = rect.left.saturating_add(offset); + rect.right = rect.left.saturating_add(width); + cell.set_layout_rect(rect, bounds, clip); + cell.layout_children(cell.inner_clipped); + } +} + /// NOTE: Must not contain items that require drop(). struct StyledTextChunk { offset: usize, @@ -3804,6 +3992,7 @@ enum NodeContent<'a> { List(ListContent<'a>), Modal(BString<'a>), // title Table(TableContent<'a>), + Grid(GridContent<'a>), Text(TextContent<'a>), Textarea(TextareaContent<'a>), Scrollarea(ScrollareaContent), @@ -3880,6 +4069,18 @@ struct Node<'a> { } impl<'a> Node<'a> { + fn set_layout_rect(&mut self, rect: Rect, bounds: Rect, clip: Rect) { + self.outer = Rect { + left: rect.left.clamp(bounds.left, bounds.right), + top: rect.top.clamp(bounds.top, bounds.bottom), + right: rect.right.clamp(bounds.left, bounds.right), + bottom: rect.bottom.clamp(bounds.top, bounds.bottom), + }; + self.inner = self.outer_to_inner(self.outer); + self.outer_clipped = self.outer.intersect(clip); + self.inner_clipped = self.inner.intersect(clip); + } + /// Given an outer rectangle (including padding and borders) of this node, /// this returns the inner rectangle (excluding padding and borders). fn outer_to_inner(&self, mut outer: Rect) -> Rect { @@ -3888,10 +4089,14 @@ impl<'a> Node<'a> { let r = self.attributes.bordered || matches!(self.content, NodeContent::Scrollarea(..)); let b = self.attributes.bordered; - outer.left += self.attributes.padding.left + l as CoordType; - 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; + let left = outer.left.saturating_add(self.attributes.padding.left); + let top = outer.top.saturating_add(self.attributes.padding.top); + let right = outer.right.saturating_sub(self.attributes.padding.right); + let bottom = outer.bottom.saturating_sub(self.attributes.padding.bottom); + outer.left = left.saturating_add(l as CoordType).min(outer.right); + outer.top = top.saturating_add(t as CoordType).min(outer.bottom); + outer.right = right.saturating_sub(r as CoordType).max(outer.left); + outer.bottom = bottom.saturating_sub(b as CoordType).max(outer.top); outer } @@ -3918,6 +4123,13 @@ impl<'a> Node<'a> { /// Computes the intrinsic size of this node and its children. fn compute_intrinsic_size(&mut self, arena: &'a Arena) { match &mut self.content { + NodeContent::Grid(grid) => { + let size = grid.measure(self.children.first, arena); + if !self.intrinsic_size_set { + self.intrinsic_size = size; + self.intrinsic_size_set = true; + } + } NodeContent::Table(spec) => { // Calculate each row's height and the maximum width of each of its columns. for row in Tree::iterate_siblings(self.children.first) { @@ -4005,11 +4217,21 @@ 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) { - if self.children.first.is_none() || self.inner.is_empty() { + if self.children.first.is_none() { + return; + } + if self.inner.is_empty() { + let empty = Rect { right: self.inner.left, bottom: self.inner.top, ..self.inner }; + for child in Tree::iterate_siblings(self.children.first) { + let mut child = child.borrow_mut(); + child.set_layout_rect(empty, empty, clip); + child.layout_children(clip); + } return; } match &mut self.content { + NodeContent::Grid(grid) => grid.layout(self.children.first, self.inner, clip), NodeContent::Table(spec) => { let width = self.inner.right - self.inner.left; let mut x = self.inner.left; diff --git a/crates/edit/src/tui/grid_tests.rs b/crates/edit/src/tui/grid_tests.rs new file mode 100644 index 00000000000..f5f607d69a5 --- /dev/null +++ b/crates/edit/src/tui/grid_tests.rs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use GridTrack::{Auto, Fixed, Fraction}; + +use super::*; + +fn node<'a>(tui: &'a Tui, name: &str) -> &'a NodeCell<'static> { + iter::successors(Some(tui.prev_tree.root_first), |node| node.borrow().next) + .find(|node| node.borrow().classname == name) + .unwrap() +} + +fn rect(tui: &Tui, name: &str) -> Rect { + node(tui, name).borrow().outer +} + +fn pane(ctx: &mut Context<'_, '_>, name: &'static str, width: CoordType, height: CoordType) { + ctx.block_begin(name); + ctx.attr_intrinsic_size(Size { width, height }); + ctx.block_end(); +} + +fn grid( + tui: &mut Tui, + columns: &[GridTrack], + rows: &[GridTrack], + draw: impl FnOnce(&mut Context<'_, '_>), +) { + let mut ctx = tui.create_context(None); + ctx.attr_display(Display::Grid); + ctx.attr_grid_template_columns(columns); + ctx.attr_grid_template_rows(rows); + draw(&mut ctx); +} + +#[test] +fn grid_places_flat_children_on_both_axes() { + let mut tui = Tui::new().unwrap(); + let columns = [Fraction(1), Fraction(2)]; + let rows = [Fixed(1), Fraction(1), Fraction(1), Fixed(1)]; + for height in [9, 10, 2, 1, 0, 3, 9] { + tui.set_size(Size { width: 11, height }); + grid(&mut tui, &columns, &rows, |ctx| { + for name in ["h1", "h2", "a", "b", "c", "d", "f1", "f2"] { + pane(ctx, name, 100, 100); + } + }); + let remaining = (height - 2).max(0); + assert_eq!(rect(&tui, "a").height(), remaining / 2); + assert_eq!(rect(&tui, "c").height(), remaining - remaining / 2); + assert_eq!(rect(&tui, "a").bottom, rect(&tui, "c").top); + if height > 2 { + assert_eq!(rect(&tui, "a").width(), 3); + assert_eq!(rect(&tui, "b").left, 3); + assert_eq!(rect(&tui, "b").right, 11); + } + for name in ["h1", "a", "b", "c", "d", "f1"] { + let node = node(&tui, name).borrow(); + assert!(node.outer.top <= node.outer.bottom && node.outer.bottom <= height); + assert!(node.inner.top <= node.inner.bottom); + } + assert_eq!(rect(&tui, "f1").bottom, height); + } +} + +#[test] +fn grid_preserves_intrinsic_sizing_padding_and_excludes_floats() { + let mut tui = Tui::new().unwrap(); + tui.set_size(Size { width: 12, height: 10 }); + grid(&mut tui, &[Fraction(1)], &[Fraction(1)], |ctx| { + ctx.block_begin("nested"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_template_columns(&[Auto, Fraction(1)]); + ctx.attr_border(); + ctx.attr_padding(Rect::one(1)); + pane(ctx, "a", 3, 1); + pane(ctx, "b", 2, 1); + pane(ctx, "float", 99, 99); + ctx.attr_float(FloatSpec::default()); + pane(ctx, "c", 1, 2); + pane(ctx, "d", 1, 1); + ctx.block_end(); + }); + assert_eq!(node(&tui, "nested").borrow().intrinsic_size, Size { width: 5, height: 3 }); + assert_eq!(rect(&tui, "a"), Rect { left: 2, top: 2, right: 5, bottom: 4 }); + assert_eq!(rect(&tui, "b"), Rect { left: 5, top: 2, right: 10, bottom: 4 }); + assert_eq!(rect(&tui, "d"), Rect { left: 5, top: 4, right: 10, bottom: 8 }); + assert_eq!(tui.prev_tree.iterate_roots().count(), 2); + assert_eq!(node(&tui, "nested").borrow().child_count, 4); + assert!(!mem::needs_drop::>()); +} + +#[test] +fn grid_allocator_rounds_and_handles_zero_and_extreme_tracks() { + let arena = Arena::new(MEBI).unwrap(); + let mut tracks = + GridContent::tracks(&arena, &[Fixed(2), Auto, Fraction(1), Fraction(3), Fraction(0)]); + tracks[1].intrinsic = 3; + tracks[2].intrinsic = 999; + assert_eq!(GridContent::preferred_size(&tracks), 4001); + for available in 0..40 { + GridContent::allocate(&mut tracks, available); + let remaining = (available - 5).max(0); + let sizes: Vec<_> = tracks.iter().map(|t| t.end - t.start).collect(); + assert_eq!(sizes, [2, 3, remaining / 4, remaining - remaining / 4, 0]); + } + let extremes = [Fixed(CoordType::MIN), Fraction(u16::MAX), Fraction(u16::MAX)]; + let mut tracks = GridContent::tracks(&arena, &extremes); + GridContent::allocate(&mut tracks, CoordType::MAX); + assert_eq!(tracks[2].end, CoordType::MAX); + tracks[1].intrinsic = CoordType::MAX; + assert_eq!(GridContent::preferred_size(&tracks), CoordType::MAX); +} + +#[test] +fn grid_bounds_aligned_items_to_narrow_and_zero_columns() { + let mut tui = Tui::new().unwrap(); + tui.set_size(Size { width: 12, height: 1 }); + for track in [Fixed(4), Fixed(0), Fraction(0)] { + for width in [2, 8] { + let column_width = if matches!(track, Fixed(4)) { 4 } else { 0 }; + let spare = (column_width - width).max(0); + for (position, left) in + [(Position::Left, 0), (Position::Center, spare / 2), (Position::Right, spare)] + { + grid(&mut tui, &[track, Fraction(1)], &[Fraction(1)], |ctx| { + pane(ctx, "aligned", width, 1); + ctx.attr_position(position); + pane(ctx, "next", 1, 1); + }); + let expected = + Rect { left, top: 0, right: (left + width).min(column_width), bottom: 1 }; + assert_eq!(rect(&tui, "aligned"), expected); + assert_eq!(node(&tui, "aligned").borrow().outer_clipped, expected); + assert_eq!(rect(&tui, "next").left, column_width); + } + } + } +} + +#[test] +fn grid_default_tracks_overrides_and_collapsed_descendants() { + let mut tui = Tui::new().unwrap(); + for size in [Size { width: 1, height: 1 }, Size { width: 12, height: 10 }] { + tui.set_size(size); + grid(&mut tui, &[], &[], |ctx| { + ctx.block_begin("override"); + ctx.attr_display(Display::Grid); + ctx.attr_intrinsic_size(Size { width: 7, height: 2 }); + ctx.block_begin("border"); + ctx.attr_border(); + ctx.attr_padding(Rect::one(1)); + pane(ctx, "child", 100, 100); + ctx.block_end(); + ctx.block_end(); + }); + assert_eq!(node(&tui, "override").borrow().intrinsic_size, Size { width: 7, height: 2 }); + assert_eq!(rect(&tui, "override"), size.as_rect()); + if size.width == 1 { + for name in ["border", "child"] { + assert_eq!(node(&tui, name).borrow().inner, Rect::one(1)); + } + } + } +} + +fn menu(tui: &mut Tui, input: Option>) -> bool { + let mut ctx = tui.create_context(input); + ctx.attr_display(Display::Grid); + ctx.attr_grid_template_rows(&[Fixed(1), Fraction(1)]); + let mut clicked = false; + ctx.menubar_begin(); + if ctx.menubar_menu_begin("File", 'f') { + clicked |= ctx.menubar_menu_button("Open", 'o', kbmod::CTRL | vk::O); + clicked |= ctx.menubar_menu_button("Longer entry", 'l', kbmod::CTRL | vk::L); + ctx.menubar_menu_end(); + } + ctx.menubar_end(); + pane(&mut ctx, "body", 1, 1); + clicked +} + +fn mouse(state: InputMouseState, position: Point) -> Input<'static> { + Input::Mouse(input::InputMouse { + state, + position, + modifiers: kbmod::NONE, + scroll: Point::default(), + drag: false, + }) +} + +#[test] +fn grid_ancestor_preserves_legacy_menu_highlight_and_row_hitbox() { + let mut tui = Tui::new().unwrap(); + for height in [24, 3, 24] { + tui.set_size(Size { width: 80, height }); + menu(&mut tui, None); + } + for state in [InputMouseState::Left, InputMouseState::None] { + menu(&mut tui, Some(mouse(state, Point { x: 2, y: 0 }))); + } + let mut previous = None; + let mut target = Point::default(); + for index in [NodeChildren::FIRST, NodeChildren::LAST] { + menu(&mut tui, Some(Input::Keyboard(vk::DOWN))); + menu(&mut tui, None); + let flyout = node(&tui, "flyout").borrow(); + let row = flyout.children.get(index).unwrap().borrow(); + assert_eq!(tui.focused_node_path.last(), Some(&row.id)); + assert_eq!(row.attributes.bg, tui.indexed(IndexedColor::Green)); + assert_eq!(row.outer.width(), flyout.inner.width()); + assert_ne!(Some(row.id), previous); + previous = Some(row.id); + target = Point { x: row.outer.right - 1, y: row.outer.top }; + } + assert!(!menu(&mut tui, Some(mouse(InputMouseState::Left, target)))); + assert!(menu(&mut tui, Some(mouse(InputMouseState::None, target)))); +}