From c33ecee220dcc1aaafdce5a6597a7f7b38ddd0a9 Mon Sep 17 00:00:00 2001 From: Michael Greene Date: Tue, 22 Sep 2026 10:34:30 -0500 Subject: [PATCH] Migrate remaining layouts to the shared Grid engine Include the Grid prerequisites, replace table callers with declarative boxed rows, and remove compatibility APIs. Carry the compact placement and horizontal-inset helpers forward unchanged from the shared-engine slice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/edit/src/bin/edit/draw_editor.rs | 80 ++- crates/edit/src/bin/edit/draw_filepicker.rs | 43 +- crates/edit/src/bin/edit/draw_statusbar.rs | 55 +- crates/edit/src/bin/edit/layout_tests.rs | 151 +++++ crates/edit/src/bin/edit/main.rs | 22 +- crates/edit/src/tui.rs | 693 ++++++++++++++------ crates/edit/src/tui/grid_tests.rs | 400 +++++++++++ 7 files changed, 1189 insertions(+), 255 deletions(-) create mode 100644 crates/edit/src/bin/edit/layout_tests.rs create mode 100644 crates/edit/src/tui/grid_tests.rs diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index ce03bbeb4cb..35e2fcac6e8 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) { @@ -71,11 +70,19 @@ fn draw_search(ctx: &mut Context, state: &mut State) { state.wants_search.kind = StateSearchKind::Hidden; } - ctx.table_begin("needle"); - ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + ctx.block_begin("needle"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_grid_template_columns(&[GridTrack::Intrinsic(0), GridTrack::Fraction(1)]); + ctx.attr_focus_navigation(FocusNavigation::Vertical); + ctx.attr_grid_gap(Size { width: 1, height: 0 }); { { - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.label("label", loc(LocId::SearchNeedleLabel)); if ctx.editline("needle", &mut state.search_needle) { @@ -85,7 +92,7 @@ fn draw_search(ctx: &mut Context, state: &mut State) { ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); } - ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 }); + ctx.attr_intrinsic_size(Size { width: 0, height: 1 }); if focus == StateSearchKind::Search { ctx.steal_focus(); } @@ -95,11 +102,17 @@ fn draw_search(ctx: &mut Context, state: &mut State) { } if state.wants_search.kind == StateSearchKind::Replace { - ctx.table_next_row(); + ctx.block_end(); + ctx.next_block_id_mixin(1); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.label("label", loc(LocId::SearchReplacementLabel)); ctx.editline("replacement", &mut state.search_replacement); - ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 }); + ctx.attr_intrinsic_size(Size { width: 0, height: 1 }); if focus == StateSearchKind::Replace { ctx.steal_focus(); } @@ -112,15 +125,24 @@ fn draw_search(ctx: &mut Context, state: &mut State) { } } } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); - ctx.table_begin("options"); - ctx.table_set_cell_gap(Size { width: 2, height: 0 }); + ctx.block_begin("options"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_focus_navigation(FocusNavigation::Vertical); + ctx.attr_grid_gap(Size { width: 2, height: 0 }); { let mut change = false; let mut change_action = Some(SearchAction::Search); - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); change |= ctx.checkbox( "match-case", @@ -151,7 +173,8 @@ fn draw_search(ctx: &mut Context, state: &mut State) { action = change_action; } } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); } ctx.block_end(); @@ -238,13 +261,21 @@ pub fn draw_handle_wants_close(ctx: &mut Context, state: &mut State) { ctx.label("description", loc(LocId::UnsavedChangesDialogDescription)); ctx.attr_padding(Rect::three(1, 2, 1)); - ctx.table_begin("choices"); + ctx.block_begin("choices"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_focus_navigation(FocusNavigation::Vertical); ctx.inherit_focus(); ctx.attr_padding(Rect::three(0, 2, 1)); ctx.attr_position(Position::Center); - ctx.table_set_cell_gap(Size { width: 2, height: 0 }); + ctx.attr_grid_gap(Size { width: 2, height: 0 }); { - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.inherit_focus(); if ctx.button( @@ -275,7 +306,8 @@ pub fn draw_handle_wants_close(ctx: &mut Context, state: &mut State) { } } } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); } if ctx.modal_end() { action = Action::Cancel; diff --git a/crates/edit/src/bin/edit/draw_filepicker.rs b/crates/edit/src/bin/edit/draw_filepicker.rs index e71b7f81ec3..fe236f989cb 100644 --- a/crates/edit/src/bin/edit/draw_filepicker.rs +++ b/crates/edit/src/bin/edit/draw_filepicker.rs @@ -45,19 +45,32 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { let contains_focus = ctx.contains_focus(); let mut activated = false; - ctx.table_begin("path"); - ctx.table_set_columns(&[0, COORD_TYPE_SAFE_MAX]); - ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + ctx.block_begin("path"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_grid_template_columns(&[GridTrack::Intrinsic(0), GridTrack::Fraction(1)]); + ctx.attr_focus_navigation(FocusNavigation::Vertical); + ctx.attr_grid_gap(Size { width: 1, height: 0 }); ctx.attr_padding(Rect::two(1, 1)); ctx.inherit_focus(); { - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.label("dir-label", loc(LocId::SaveAsDialogPathLabel)); ctx.label("dir", state.file_picker_pending_dir.as_str()); ctx.attr_overflow(Overflow::TruncateMiddle); - ctx.table_next_row(); + ctx.block_end(); + ctx.next_block_id_mixin(1); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.inherit_focus(); ctx.label("name-label", loc(LocId::SaveAsDialogNameLabel)); @@ -131,7 +144,8 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { activated = true; } } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); if state.file_picker_entries.is_none() { draw_dialog_saveas_refresh_files(state); @@ -206,13 +220,21 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { ctx.attr_overflow(Overflow::TruncateTail); ctx.attr_padding(Rect::three(1, 2, 1)); - ctx.table_begin("choices"); + ctx.block_begin("choices"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_focus_navigation(FocusNavigation::Vertical); ctx.inherit_focus(); ctx.attr_padding(Rect::three(0, 2, 1)); ctx.attr_position(Position::Center); - ctx.table_set_cell_gap(Size { width: 2, height: 0 }); + ctx.attr_grid_gap(Size { width: 2, height: 0 }); { - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.inherit_focus(); save = ctx.button("yes", loc(LocId::Yes), ButtonStyle::default()); @@ -222,7 +244,8 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { state.file_picker_overwrite_warning = None; } } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); if contains_focus { save |= ctx.consume_shortcut(vk::Y); diff --git a/crates/edit/src/bin/edit/draw_statusbar.rs b/crates/edit/src/bin/edit/draw_statusbar.rs index f061527932a..c5e553ced6b 100644 --- a/crates/edit/src/bin/edit/draw_statusbar.rs +++ b/crates/edit/src/bin/edit/draw_statusbar.rs @@ -16,18 +16,26 @@ use crate::localization::*; use crate::state::*; pub fn draw_statusbar(ctx: &mut Context, state: &mut State) { - ctx.table_begin("statusbar"); + ctx.block_begin("statusbar"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_focus_navigation(FocusNavigation::Vertical); ctx.attr_focus_well(); ctx.attr_background_rgba(state.menubar_color_bg); ctx.attr_foreground_rgba(state.menubar_color_fg); - ctx.table_set_cell_gap(Size { width: 2, height: 0 }); + ctx.attr_grid_gap(Size { width: 2, height: 0 }); ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 }); ctx.attr_padding(Rect::two(0, 1)); if let Some(doc) = state.documents.active() { let mut tb = doc.buffer.borrow_mut(); - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); state.wants_language_picker |= ctx.button( "language", @@ -94,7 +102,11 @@ pub fn draw_statusbar(ctx: &mut Context, state: &mut State) { ButtonStyle::default(), ); if state.wants_indentation_picker { - ctx.table_begin("indentation-picker"); + ctx.block_begin("indentation-picker"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_focus_navigation(FocusNavigation::Vertical); ctx.attr_float(FloatSpec { anchor: Anchor::Last, gravity_x: 0.0, @@ -104,13 +116,17 @@ pub fn draw_statusbar(ctx: &mut Context, state: &mut State) { }); ctx.attr_border(); ctx.attr_padding(Rect::two(0, 1)); - ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + ctx.attr_grid_gap(Size { width: 1, height: 0 }); { if ctx.contains_focus() && ctx.consume_shortcut(vk::RETURN) { ctx.toss_focus_up(); } - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.list_begin("type"); ctx.focus_on_first_present(); @@ -148,7 +164,8 @@ pub fn draw_statusbar(ctx: &mut Context, state: &mut State) { } ctx.list_end(); } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); if !ctx.contains_focus() { state.wants_indentation_picker = false; @@ -199,13 +216,14 @@ pub fn draw_statusbar(ctx: &mut Context, state: &mut State) { ctx.attr_position(Position::Right); } ctx.block_end(); + ctx.block_end(); } else { state.wants_statusbar_focus = false; state.wants_encoding_picker = false; state.wants_indentation_picker = false; } - ctx.table_end(); + ctx.block_end(); } pub fn draw_dialog_language_change(ctx: &mut Context, state: &mut State) { @@ -270,12 +288,22 @@ pub fn draw_dialog_encoding_change(ctx: &mut Context, state: &mut State) { if reopen { loc(LocId::EncodingReopen) } else { loc(LocId::EncodingConvert) }, ); { - ctx.table_begin("encoding-search"); - ctx.table_set_columns(&[0, COORD_TYPE_SAFE_MAX]); - ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + ctx.block_begin("encoding-search"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_grid_template_columns(&[ + GridTrack::Intrinsic(0), + GridTrack::Intrinsic(COORD_TYPE_SAFE_MAX), + ]); + ctx.attr_focus_navigation(FocusNavigation::Vertical); + ctx.attr_grid_gap(Size { width: 1, height: 0 }); ctx.inherit_focus(); { - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.inherit_focus(); ctx.label("needle-label", loc(LocId::SearchNeedleLabel)); @@ -285,7 +313,8 @@ pub fn draw_dialog_encoding_change(ctx: &mut Context, state: &mut State) { } ctx.inherit_focus(); } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); ctx.scrollarea_begin("scrollarea", Size { width, height }); ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 1, 4)); 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..6045324acc1 --- /dev/null +++ b/crates/edit/src/bin/edit/layout_tests.rs @@ -0,0 +1,151 @@ +// 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", "dialog", "file-picker"] { + 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))); + } + if phase == "dialog" { + // save_as_string below marks the document clean. + frame(&mut tui, &mut state, Some(Input::Text("!"))); + expected.push('!'); + state.wants_close = true; + frame(&mut tui, &mut state, None); + for key in [vk::TAB, kbmod::SHIFT | vk::TAB, vk::RIGHT, vk::RIGHT, vk::RETURN] { + frame(&mut tui, &mut state, Some(Input::Keyboard(key))); + } + assert!(!state.wants_close && !state.wants_save); + assert!(state.documents.active().is_some()); + } + if phase == "file-picker" { + state.wants_file_picker = StateFilePicker::SaveAsShown; + state.file_picker_pending_name = "layout.txt".into(); + state.file_picker_entries = Some(Default::default()); + frame(&mut tui, &mut state, None); + { + let scratch = arena::scratch_arena(None); + let layout = tui.debug_layout(&scratch); + assert_eq!(outer(&layout, "dir").left, outer(&layout, "name").left); + } + frame(&mut tui, &mut state, Some(Input::Keyboard(kbmod::CTRL | vk::END))); + frame(&mut tui, &mut state, Some(Input::Text("x"))); + assert_eq!(state.file_picker_pending_name, std::path::Path::new("layout.txtx")); + frame(&mut tui, &mut state, Some(Input::Keyboard(vk::ESCAPE))); + assert!(state.wants_file_picker == StateFilePicker::None); + } + 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..562763db91b 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); @@ -506,13 +511,21 @@ fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) { } ctx.block_end(); - ctx.table_begin("choices"); + ctx.block_begin("choices"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + ctx.attr_focus_navigation(FocusNavigation::Vertical); ctx.inherit_focus(); ctx.attr_padding(Rect::three(0, 2, 1)); ctx.attr_position(Position::Center); - ctx.table_set_cell_gap(Size { width: 2, height: 0 }); + ctx.attr_grid_gap(Size { width: 2, height: 0 }); { - ctx.table_next_row(); + ctx.block_begin("row"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_focus_navigation(FocusNavigation::Horizontal); ctx.inherit_focus(); if over_limit { @@ -541,7 +554,8 @@ fn draw_handle_clipboard_change(ctx: &mut Context, state: &mut State) { } } } - ctx.table_end(); + ctx.block_end(); + ctx.block_end(); } if ctx.modal_end() { done = Some(false); diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 53f0233d555..a8e19df1a81 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -125,10 +125,13 @@ //! } //! //! fn draw(ctx: &mut Context, state: &mut State) { -//! ctx.table_begin("classname"); +//! ctx.block_begin("classname"); +//! ctx.attr_display(Display::Grid); +//! ctx.attr_grid_template_columns(&[GridTrack::Intrinsic(0); 2]); +//! ctx.attr_grid_auto_rows(GridTrack::Intrinsic(0)); +//! ctx.attr_grid_align_items(GridAlignment::Start); +//! ctx.attr_focus_navigation(FocusNavigation::Horizontal); //! { -//! ctx.table_next_row(); -//! //! // Thanks to the lack of callbacks, we can use a primitive //! // if condition here, as well as in any potential C code. //! if ctx.button("button", "Click me!", ButtonStyle::default()) { @@ -139,7 +142,7 @@ //! // It's impossible to forget updating the label this way. //! ctx.label("label", &arena_format!(ctx.arena(), "Counter: {}", state.counter)); //! } -//! ctx.table_end(); +//! ctx.block_end(); //! } //! ``` @@ -162,6 +165,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 +252,42 @@ 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, + /// Intrinsic content size with a minimum cell count; never stretches. + Intrinsic(CoordType), + /// An exact cell count; negative sizes become zero. + Fixed(CoordType), + /// A fractional track with a zero minimum: `minmax(0, Nfr)`. + Fraction(u16), +} + +/// Alignment of items within their grid cells. +#[derive(Default, Clone, Copy)] +pub enum GridAlignment { + #[default] + Stretch, + Start, + Center, + End, +} + +/// Arrow-key traversal between a container's focusable children. +#[derive(Clone, Copy)] +pub enum FocusNavigation { + Horizontal, + Vertical, +} + /// Controls the text overflow behavior of a label /// when the text doesn't fit the container. #[derive(Default, Clone, Copy, PartialEq, Eq)] @@ -1497,6 +1539,12 @@ impl<'a> Context<'a, '_> { pub fn block_end(&mut self) { self.tree.pop_stack(); self.block_end_move_focus(); + let navigation = self.tree.last_node.borrow().attributes.focus_navigation; + match navigation { + Some(FocusNavigation::Horizontal) => self.move_focus(vk::LEFT, vk::RIGHT), + Some(FocusNavigation::Vertical) => self.move_focus(vk::UP, vk::DOWN), + None => {} + } } fn block_end_move_focus(&mut self) { @@ -1698,6 +1746,101 @@ 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 implicit column. Boxed column subgrids can + /// create additional implicit columns; otherwise items wrap at the template width. + 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"); + } + } + + /// Sets the size of columns created implicitly (Auto by default). + pub fn attr_grid_auto_columns(&mut self, track: GridTrack) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.auto_columns = track; + } else { + debug_assert!(false, "grid-auto-columns requires display: grid"); + } + } + + /// Sets the size of rows created implicitly (Auto by default). + pub fn attr_grid_auto_rows(&mut self, track: GridTrack) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.auto_rows = track; + } else { + debug_assert!(false, "grid-auto-rows requires display: grid"); + } + } + + /// Starts a boxed row sharing its Grid parent's columns/gap instead of its own templates. + /// Cells contribute intrinsic widths; border/padding inset edge tracks, not the hitbox. + /// Spans all columns after preceding flat items. Nested column subgrids are unsupported. + /// For non-stretching forms, use parent `Intrinsic(0)` tracks and row `Start` alignment. + pub fn attr_grid_column_subgrid(&mut self) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.column_subgrid = true; + } else { + debug_assert!(false, "column subgrid requires display: grid"); + } + } + + /// Sets nonnegative column and row gaps. Subgrid columns inherit their gap. + pub fn attr_grid_gap(&mut self, gap: Size) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.cell_gap = Size { width: gap.width.max(0), height: gap.height.max(0) }; + } else { + debug_assert!(false, "grid gap requires display: grid"); + } + } + + /// Sets vertical item alignment (Stretch by default). + pub fn attr_grid_align_items(&mut self, alignment: GridAlignment) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.align_items = alignment; + } else { + debug_assert!(false, "grid alignment requires display: grid"); + } + } + + /// Overrides horizontal item alignment; otherwise each item's Position applies. + pub fn attr_grid_justify_items(&mut self, alignment: GridAlignment) { + if let NodeContent::Grid(grid) = &mut self.tree.last_node.borrow_mut().content { + grid.justify_items = Some(alignment); + } else { + debug_assert!(false, "grid alignment requires display: grid"); + } + } + + /// Enables arrow traversal after normal Tab handling at block_end. + /// Descendants get the first opportunity to consume the input. + pub fn attr_focus_navigation(&mut self, navigation: FocusNavigation) { + self.tree.last_node.borrow_mut().attributes.focus_navigation = Some(navigation); + } + /// Assigns padding to the current node. pub fn attr_padding(&mut self, padding: Rect) { let mut last_node = self.tree.last_node.borrow_mut(); @@ -1826,89 +1969,8 @@ impl<'a> Context<'a, '_> { } } - /// Begins a table block. Call [`Context::table_end()`]. - /// Tables are the primary way to create a grid layout, - /// and to layout controls on a single row (= a table with 1 row). - pub fn table_begin(&mut self, classname: &'static str) { - self.block_begin(classname); - - let mut last_node = self.tree.last_node.borrow_mut(); - last_node.content = NodeContent::Table(TableContent { - columns: BVec::empty(), - cell_gap: Default::default(), - }); - } - - /// Assigns widths to the columns of the current table. - /// By default, the table will left-align all columns. - pub fn table_set_columns(&mut self, columns: &[CoordType]) { - let mut last_node = self.tree.last_node.borrow_mut(); - if let NodeContent::Table(spec) = &mut last_node.content { - spec.columns.clear(); - spec.columns.extend_from_slice(self.arena(), columns); - } else { - debug_assert!(false); - } - } - - /// Assigns the gap between cells in the current table. - pub fn table_set_cell_gap(&mut self, cell_gap: Size) { - let mut last_node = self.tree.last_node.borrow_mut(); - if let NodeContent::Table(spec) = &mut last_node.content { - spec.cell_gap = cell_gap; - } else { - debug_assert!(false); - } - } - - /// Starts the next row in the current table. - pub fn table_next_row(&mut self) { - { - let current_node = self.tree.current_node.borrow(); - - // If this is the first call to table_next_row() inside a new table, the - // current_node will refer to the table. Otherwise, it'll refer to the current row. - if !matches!(current_node.content, NodeContent::Table(_)) { - let Some(parent) = current_node.parent else { - return; - }; - - let parent = parent.borrow(); - // Neither the current nor its parent nodes are a table? - // You definitely called this outside of a table block. - debug_assert!(matches!(parent.content, NodeContent::Table(_))); - - self.block_end(); - self.table_end_row(); - - self.next_block_id_mixin(parent.child_count as u64); - } - } - - self.block_begin("row"); - } - - fn table_end_row(&mut self) { - self.table_move_focus(vk::LEFT, vk::RIGHT); - } - - /// Ends the current table block. - pub fn table_end(&mut self) { - let current_node = self.tree.current_node.borrow(); - - // If this is the first call to table_next_row() inside a new table, the - // current_node will refer to the table. Otherwise, it'll refer to the current row. - if !matches!(current_node.content, NodeContent::Table(_)) { - self.block_end(); - self.table_end_row(); - } - - self.block_end(); // table - self.table_move_focus(vk::UP, vk::DOWN); - } - - fn table_move_focus(&mut self, prev_key: InputKey, next_key: InputKey) { - // Filter down to table rows that are focused. + fn move_focus(&mut self, prev_key: InputKey, next_key: InputKey) { + // Filter down to containers that are focused. if !self.contains_focus() { return; } @@ -1932,8 +1994,7 @@ impl<'a> Context<'a, '_> { let mut prev_next = NodeSiblings { prev: None, next: None }; let mut focused = None; - // Iterate through the cells in the row / the rows in the table, looking for focused_id. - // Take note of the previous and next focusable cells / rows around the focused one. + // Find the previous and next focusable children around focused_id. for cell in Tree::iterate_siblings(container.borrow().children.first) { let n = cell.borrow(); if n.id == focused_id { @@ -3195,9 +3256,24 @@ impl<'a> Context<'a, '_> { /// Creates a menubar, to be shown at the top of the screen. pub fn menubar_begin(&mut self) { - self.table_begin("menubar"); + self.block_begin("menubar"); + self.attr_display(Display::Grid); + self.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + self.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + self.attr_focus_navigation(FocusNavigation::Vertical); self.attr_focus_well(); - self.table_next_row(); + self.menubar_row_begin(); + } + + fn menubar_row_begin(&mut self) { + let mixin = self.tree.current_node.borrow().child_count as u64; + self.next_block_id_mixin(mixin); + self.block_begin("row"); + self.attr_display(Display::Grid); + self.attr_grid_column_subgrid(); + self.attr_grid_align_items(GridAlignment::Start); + self.attr_grid_justify_items(GridAlignment::Stretch); + self.attr_focus_navigation(FocusNavigation::Horizontal); } /// Appends a menu to the current menubar. @@ -3246,7 +3322,11 @@ impl<'a> Context<'a, '_> { } self.next_block_id_mixin(mixin); - self.table_begin("flyout"); + self.block_begin("flyout"); + self.attr_display(Display::Grid); + self.attr_grid_auto_columns(GridTrack::Intrinsic(0)); + self.attr_grid_auto_rows(GridTrack::Intrinsic(0)); + self.attr_focus_navigation(FocusNavigation::Vertical); self.attr_float(FloatSpec { anchor: Anchor::Last, gravity_x: 0.0, @@ -3286,7 +3366,7 @@ impl<'a> Context<'a, '_> { shortcut: InputKey, checked: bool, ) -> bool { - self.table_next_row(); + self.menubar_row_begin(); self.attr_focusable(); // First menu item? Steal focus. @@ -3315,12 +3395,13 @@ impl<'a> Context<'a, '_> { Tui::clean_node_path(&mut self.tui.focused_node_path); } + self.block_end(); clicked } /// Ends the current menu. pub fn menubar_menu_end(&mut self) { - self.table_end(); + self.block_end(); if !self.input_consumed && let Some(key) = self.input_keyboard @@ -3348,7 +3429,8 @@ impl<'a> Context<'a, '_> { /// Ends the current menubar. pub fn menubar_end(&mut self) { - self.table_end(); + self.block_end(); // row + self.block_end(); // menubar if !self.contains_focus() { self.tui.menubar_toggle_id = 0; @@ -3741,6 +3823,7 @@ struct NodeAttributes { focusable: bool, focus_well: bool, // Prevents focus from leaving via Tab focus_void: bool, // Prevents focus from entering via Tab + focus_navigation: Option, } /// NOTE: Must not contain items that require drop(). @@ -3750,10 +3833,271 @@ struct ListContent<'a> { selected_node: Option<&'a NodeCell<'a>>, } -/// NOTE: Must not contain items that require drop(). -struct TableContent<'a> { - columns: BVec<'a, CoordType>, +#[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>, cell_gap: Size, + auto_columns: GridTrack, + auto_rows: GridTrack, + column_subgrid: bool, + align_items: GridAlignment, + justify_items: Option, +} + +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 + } + + /// Flat items auto-place in row-major order; boxed subgrids start a full row. + fn items( + first: Option<&'a NodeCell<'a>>, + columns: usize, + ) -> impl Iterator, usize, usize)> { + let mut slot = 0; + Tree::iterate_siblings(first).map(move |child| { + let shared = child.borrow().is_column_subgrid(); + if shared && slot % columns != 0 { + slot += columns - slot % columns; + } + let item = (child, slot / columns, slot % columns); + slot += if shared { columns } else { 1 }; + item + }) + } + + fn measure(&mut self, first: Option<&'a NodeCell<'a>>, arena: &'a Arena) -> Size { + let columns = Tree::iterate_siblings(first) + .filter(|child| child.borrow().is_column_subgrid()) + .map(|child| child.borrow().child_count) + .max() + .unwrap_or(0) + .max(self.columns.len()) + .max(1); + while self.columns.len() < columns { + self.columns + .push(arena, GridTrackSize { template: self.auto_columns, ..Default::default() }); + } + for (child, row, column) in Self::items(first, columns) { + while self.rows.len() <= row { + self.rows + .push(arena, GridTrackSize { template: self.auto_rows, ..Default::default() }); + } + let mut child = child.borrow_mut(); + if child.is_column_subgrid() { + let mut height = 0; + let (left_inset, right_inset) = child.grid_insets(); + for (column, cell) in Tree::iterate_siblings(child.children.first).enumerate() { + let mut cell = cell.borrow_mut(); + debug_assert!(!cell.is_column_subgrid(), "nested column subgrid"); + cell.compute_intrinsic_size(arena); + let size = cell.intrinsic_to_outer(); + let width = size + .width + .saturating_add(if column == 0 { left_inset } else { 0 }) + .saturating_add(if column + 1 == columns { right_inset } else { 0 }); + self.columns[column].intrinsic = self.columns[column].intrinsic.max(width); + height = height.max(size.height); + } + // Insets still contribute when a row has no cells in an edge track. + let edge = left_inset.saturating_add(if columns == 1 { right_inset } else { 0 }); + self.columns[0].intrinsic = self.columns[0].intrinsic.max(edge); + let last = &mut self.columns[columns - 1]; + last.intrinsic = last.intrinsic.max(right_inset); + if !child.intrinsic_size_set { + child.intrinsic_size.height = height; + } + } else { + child.compute_intrinsic_size(arena); + self.columns[column].intrinsic = + self.columns[column].intrinsic.max(child.intrinsic_to_outer().width); + } + self.rows[row].intrinsic = + self.rows[row].intrinsic.max(child.intrinsic_to_outer().height); + } + let width = Self::preferred_size(&self.columns, self.cell_gap.width); + for child in Tree::iterate_siblings(first) { + let mut child = child.borrow_mut(); + if child.is_column_subgrid() && !child.intrinsic_size_set { + let (left_inset, right_inset) = child.grid_insets(); + child.intrinsic_size.width = + width.saturating_sub(left_inset).saturating_sub(right_inset).max(0); + child.intrinsic_size_set = true; + } + } + Size { width, height: Self::preferred_size(&self.rows, self.cell_gap.height) } + } + + fn preferred_size(tracks: &[GridTrackSize], gap: CoordType) -> CoordType { + let mut size = gap.saturating_mul(tracks.len().saturating_sub(1) as CoordType); + 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::Intrinsic(minimum) => { + size = size.saturating_add(track.intrinsic.max(minimum.max(0))); + } + 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, gap: CoordType) { + let gaps = gap.saturating_mul(tracks.len().saturating_sub(1) as CoordType); + let mut remaining = available.saturating_sub(gaps).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::Intrinsic(minimum) => track.intrinsic.max(minimum.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.saturating_add(gap); + assigned = share; + } + } + + fn layout(&mut self, first: Option<&'a NodeCell<'a>>, inner: Rect, clip: Rect) { + Self::allocate(&mut self.columns, inner.width(), self.cell_gap.width); + Self::allocate(&mut self.rows, inner.height(), self.cell_gap.height); + for (child, row, column) in Self::items(first, self.columns.len()) { + let row = &self.rows[row]; + let row_rect = Rect { + left: inner.left, + top: inner.top.saturating_add(row.start), + right: inner.right, + bottom: inner.top.saturating_add(row.end), + }; + let mut child = child.borrow_mut(); + if child.is_column_subgrid() { + child.set_layout_rect(row_rect, inner, clip); + let (left_inset, right_inset) = child.grid_insets(); + let NodeContent::Grid(grid) = &child.content else { unreachable!() }; + for (column, cell) in Tree::iterate_siblings(child.children.first).enumerate() { + let track = &self.columns[column]; + let left = inner.left.saturating_add(track.start); + let right = inner.left.saturating_add(track.end); + let left = if column == 0 { left.saturating_add(left_inset) } else { left }; + let right = if column + 1 == self.columns.len() { + right.saturating_sub(right_inset) + } else { + right + }; + let rect = Rect { left, right: right.max(left), ..child.inner }; + Self::place_item( + &mut cell.borrow_mut(), + rect, + child.inner, + child.inner_clipped, + grid.align_items, + grid.justify_items, + ); + } + } else { + let column = &self.columns[column]; + let rect = Rect { + left: inner.left.saturating_add(column.start), + right: inner.left.saturating_add(column.end), + ..row_rect + }; + Self::place_item( + &mut child, + rect, + inner, + clip, + self.align_items, + self.justify_items, + ); + } + } + } + + fn place_item( + cell: &mut Node<'a>, + mut rect: Rect, + bounds: Rect, + clip: Rect, + vertical: GridAlignment, + horizontal: Option, + ) { + let horizontal = horizontal.unwrap_or(match cell.attributes.position { + Position::Stretch => GridAlignment::Stretch, + Position::Left => GridAlignment::Start, + Position::Center => GridAlignment::Center, + Position::Right => GridAlignment::End, + }); + let size = cell.intrinsic_to_outer(); + let (x, width) = Self::align(horizontal, rect.width(), size.width); + let (y, height) = Self::align(vertical, rect.height(), size.height); + rect.left = rect.left.saturating_add(x); + rect.top = rect.top.saturating_add(y); + rect.right = rect.left.saturating_add(width); + rect.bottom = rect.top.saturating_add(height); + cell.set_layout_rect(rect, bounds, clip); + cell.layout_children(cell.inner_clipped); + } + + fn align( + alignment: GridAlignment, + available: CoordType, + intrinsic: CoordType, + ) -> (CoordType, CoordType) { + let size = intrinsic.min(available); + match alignment { + GridAlignment::Stretch => (0, available), + GridAlignment::Start => (0, size), + GridAlignment::Center => ((available - size) / 2, size), + GridAlignment::End => (available - size, size), + } + } } /// NOTE: Must not contain items that require drop(). @@ -3803,7 +4147,7 @@ enum NodeContent<'a> { None, List(ListContent<'a>), Modal(BString<'a>), // title - Table(TableContent<'a>), + Grid(GridContent<'a>), Text(TextContent<'a>), Textarea(TextareaContent<'a>), Scrollarea(ScrollareaContent), @@ -3880,6 +4224,28 @@ struct Node<'a> { } impl<'a> Node<'a> { + fn is_column_subgrid(&self) -> bool { + matches!(&self.content, NodeContent::Grid(grid) if grid.column_subgrid) + } + + fn grid_insets(&self) -> (CoordType, CoordType) { + let border = CoordType::from(self.attributes.bordered); + let padding = self.attributes.padding; + (padding.left.saturating_add(border), padding.right.saturating_add(border)) + } + + 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 +4254,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,65 +4288,11 @@ 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::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) { - let mut row = row.borrow_mut(); - let mut row_height = 0; - - for (column, cell) in Tree::iterate_siblings(row.children.first).enumerate() { - let mut cell = cell.borrow_mut(); - cell.compute_intrinsic_size(arena); - - let size = cell.intrinsic_to_outer(); - - // If the spec.columns[] value is positive, it's an absolute width. - // Otherwise, it's a fraction of the remaining space. - // - // TODO: The latter is computed incorrectly. - // Example: If the items are "a","b","c" then the intrinsic widths are [1,1,1]. - // If the column spec is [0,-3,-1], then this code assigns an intrinsic row - // width of 3, but it should be 5 (1+1+3), because the spec says that the - // last column (flexible 1/1) must be 3 times as wide as the 2nd one (1/3rd). - // It's not a big deal yet, because such functionality isn't needed just yet. - if column >= spec.columns.len() { - spec.columns.push(arena, 0); - } - spec.columns[column] = spec.columns[column].max(size.width); - - row_height = row_height.max(size.height); - } - - row.intrinsic_size.height = row_height; - } - - // Assuming each column has the width of the widest cell in that column, - // calculate the total width of the table. - let total_gap_width = - spec.cell_gap.width * spec.columns.len().saturating_sub(1) as CoordType; - let total_inner_width = spec.columns.iter().sum::() + total_gap_width; - let mut total_width = 0; - let mut total_height = 0; - - // Assign the total width to each row. - for row in Tree::iterate_siblings(self.children.first) { - let mut row = row.borrow_mut(); - row.intrinsic_size.width = total_inner_width; - row.intrinsic_size_set = true; - - let size = row.intrinsic_to_outer(); - total_width = total_width.max(size.width); - total_height += size.height; - } - - let total_gap_height = - spec.cell_gap.height * self.child_count.saturating_sub(1) as CoordType; - total_height += total_gap_height; - - // Assign the total width/height to the table. + NodeContent::Grid(grid) => { + debug_assert!(!grid.column_subgrid, "column subgrid requires a grid parent"); + let size = grid.measure(self.children.first, arena); if !self.intrinsic_size_set { - self.intrinsic_size.width = total_width; - self.intrinsic_size.height = total_height; + self.intrinsic_size = size; self.intrinsic_size_set = true; } } @@ -4005,53 +4321,22 @@ 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::Table(spec) => { - let width = self.inner.right - self.inner.left; - let mut x = self.inner.left; - let mut y = self.inner.top; - - for row in Tree::iterate_siblings(self.children.first) { - let mut row = row.borrow_mut(); - let mut size = row.intrinsic_to_outer(); - size.width = width; - row.outer.left = x; - row.outer.top = y; - row.outer.right = x + size.width; - row.outer.bottom = y + size.height; - row.outer = row.outer.intersect(self.inner); - row.inner = row.outer_to_inner(row.outer); - row.outer_clipped = row.outer.intersect(clip); - row.inner_clipped = row.inner.intersect(clip); - - let mut row_height = 0; - - for (column, cell) in Tree::iterate_siblings(row.children.first).enumerate() { - let mut cell = cell.borrow_mut(); - let mut size = cell.intrinsic_to_outer(); - size.width = spec.columns[column]; - cell.outer.left = x; - cell.outer.top = y; - cell.outer.right = x + size.width; - cell.outer.bottom = y + size.height; - cell.outer = cell.outer.intersect(self.inner); - cell.inner = cell.outer_to_inner(cell.outer); - cell.outer_clipped = cell.outer.intersect(clip); - cell.inner_clipped = cell.inner.intersect(clip); - - x += size.width + spec.cell_gap.width; - row_height = row_height.max(size.height); - - cell.layout_children(clip); - } - - x = self.inner.left; - y += row_height + spec.cell_gap.height; - } + NodeContent::Grid(grid) => { + grid.layout(self.children.first, self.inner, clip); } NodeContent::Scrollarea(sc) => { let mut content = self.children.first.unwrap().borrow_mut(); diff --git a/crates/edit/src/tui/grid_tests.rs b/crates/edit/src/tui/grid_tests.rs new file mode 100644 index 00000000000..05b6c3e22fc --- /dev/null +++ b/crates/edit/src/tui/grid_tests.rs @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use GridTrack::{Auto, Fixed, Fraction, Intrinsic}; + +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); +} + +fn subgrid_begin(ctx: &mut Context<'_, '_>, name: &'static str) { + ctx.block_begin(name); + ctx.attr_display(Display::Grid); + ctx.attr_grid_column_subgrid(); +} + +#[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; + for gap in [0, 1, 4] { + assert_eq!(GridContent::preferred_size(&tracks, gap), 4001 + 4 * gap); + for available in 0..40 { + GridContent::allocate(&mut tracks, available, gap); + let remaining = (available - 5 - 4 * gap).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]); + assert!(tracks.windows(2).all(|pair| pair[1].start - pair[0].end == gap)); + } + } + let extremes = [Fixed(CoordType::MIN), Fraction(u16::MAX), Fraction(u16::MAX)]; + let mut tracks = GridContent::tracks(&arena, &extremes); + GridContent::allocate(&mut tracks, CoordType::MAX, 0); + assert_eq!(tracks[2].end, CoordType::MAX); + tracks[1].intrinsic = CoordType::MAX; + assert_eq!(GridContent::preferred_size(&tracks, 0), 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)); + } + } + } +} + +#[test] +fn grid_shared_cells_preserve_minimum_tracks_and_bounded_placement() { + let mut tui = Tui::new().unwrap(); + for (columns, natural_width) in [ + (&[Intrinsic(3), Intrinsic(20)][..], 26), + (&[Intrinsic(1), Intrinsic(-1)][..], 10), + (&[][..], 10), + ] { + for (width, height) in [(30, 20), (12, 6), (5, 9), (2, 2), (1, 1), (0, 0), (30, 20)] { + tui.set_size(Size { width, height }); + { + let mut ctx = tui.create_context(None); + ctx.block_begin("grid"); + ctx.attr_display(Display::Grid); + ctx.attr_grid_template_columns(columns); + ctx.attr_grid_auto_columns(Intrinsic(0)); + ctx.attr_grid_auto_rows(Intrinsic(0)); + ctx.attr_grid_gap(Size { width: 1, height: 1 }); + subgrid_begin(&mut ctx, "first"); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_grid_justify_items(GridAlignment::Stretch); + ctx.block_begin("a"); + ctx.attr_display(Display::Grid); + ctx.attr_intrinsic_size(Size { width: 1, height: 1 }); + ctx.attr_border(); + pane(&mut ctx, "child", 8, 8); + ctx.block_end(); + pane(&mut ctx, "b", 1, 1); + ctx.attr_position(Position::Right); + pane(&mut ctx, "float", 99, 99); + ctx.attr_float(FloatSpec::default()); + ctx.block_end(); + subgrid_begin(&mut ctx, "empty"); + ctx.block_end(); + subgrid_begin(&mut ctx, "padded"); + ctx.attr_grid_align_items(GridAlignment::Start); + ctx.attr_border(); + ctx.attr_padding(Rect::one(1)); + pane(&mut ctx, "short", 3, 1); + pane(&mut ctx, "tall", 2, 3); + ctx.block_end(); + subgrid_begin(&mut ctx, "last"); + pane(&mut ctx, "c", 5, 1); + ctx.block_end(); + ctx.block_end(); + } + let grid = node(&tui, "grid").borrow(); + assert_eq!(grid.intrinsic_size, Size { width: natural_width, height: 14 }); + assert_eq!(tui.prev_tree.iterate_roots().count(), 2); + for row in Tree::iterate_siblings(grid.children.first) { + assert_eq!(row.borrow().outer.width(), grid.inner.width()); + } + assert_eq!(rect(&tui, "a").right, width.min(5)); + assert_eq!(rect(&tui, "b").left, width.min(6)); + assert_eq!(rect(&tui, "b").right, width.min(natural_width)); + assert_eq!(rect(&tui, "b").height(), height.min(1)); + assert_eq!(rect(&tui, "c").top, height.min(13)); + for name in ["a", "b", "c", "child", "short", "tall"] { + let n = node(&tui, name).borrow(); + assert!(n.outer.left <= n.outer.right && n.outer.top <= n.outer.bottom); + assert!(n.inner.left <= n.inner.right && n.inner.top <= n.inner.bottom); + assert!(n.outer.right <= width && n.outer.bottom <= height); + } + let a = node(&tui, "a").borrow(); + assert_eq!(node(&tui, "child").borrow().outer_clipped, a.inner_clipped); + let row = node(&tui, "padded").borrow(); + assert_eq!(row.intrinsic_size, Size { width: natural_width - 4, height: 3 }); + for name in ["short", "tall"] { + let cell = node(&tui, name).borrow(); + assert!(cell.outer.left >= row.inner.left && cell.outer.right <= row.inner.right); + assert!(cell.outer.top >= row.inner.top && cell.outer.bottom <= row.inner.bottom); + assert_eq!(cell.outer_clipped, cell.outer.intersect(row.inner_clipped)); + } + if width == 30 { + assert_eq!(rect(&tui, "short"), Rect { left: 2, top: 7, right: 5, bottom: 8 }); + let expected = Rect { left: 6, top: 7, right: natural_width - 2, bottom: 10 }; + assert_eq!(rect(&tui, "tall"), expected); + assert_eq!(rect(&tui, "tall").left, rect(&tui, "b").left); + } + } + } +} + +#[test] +fn grid_intrinsic_tracks_gaps_and_alignment_work_without_shared_rows() { + let mut tui = Tui::new().unwrap(); + tui.set_size(Size { width: 30, height: 12 }); + grid(&mut tui, &[Intrinsic(3), Intrinsic(0)], &[], |ctx| { + ctx.attr_grid_auto_rows(Intrinsic(0)); + ctx.attr_grid_gap(Size { width: 2, height: 1 }); + ctx.attr_grid_align_items(GridAlignment::Start); + pane(ctx, "short", 1, 1); + pane(ctx, "tall", 4, 3); + pane(ctx, "next", 2, 1); + }); + assert_eq!(rect(&tui, "short"), Rect { left: 0, top: 0, right: 3, bottom: 1 }); + assert_eq!(rect(&tui, "tall"), Rect { left: 5, top: 0, right: 9, bottom: 3 }); + assert_eq!(rect(&tui, "next"), Rect { left: 0, top: 4, right: 3, bottom: 5 }); + + grid(&mut tui, &[Fixed(3), Fraction(1)], &[], |ctx| { + ctx.attr_grid_auto_rows(Intrinsic(0)); + pane(ctx, "flat", 1, 1); + subgrid_begin(ctx, "row"); + pane(ctx, "a", 1, 2); + pane(ctx, "b", 1, 1); + ctx.block_end(); + pane(ctx, "after", 1, 1); + }); + assert_eq!(rect(&tui, "flat").top, 0); + assert_eq!(rect(&tui, "a"), Rect { left: 0, top: 1, right: 3, bottom: 3 }); + assert_eq!(rect(&tui, "b"), Rect { left: 3, top: 1, right: 30, bottom: 3 }); + assert_eq!(rect(&tui, "after").top, 3); +} + +fn focus_grid(tui: &mut Tui, display: Display, input: Option>, consume: bool) { + let mut ctx = tui.create_context(input); + ctx.block_begin("container"); + ctx.attr_display(display); + if matches!(display, Display::Grid) { + ctx.attr_grid_auto_columns(Intrinsic(0)); + ctx.attr_grid_auto_rows(Intrinsic(0)); + } + ctx.attr_focus_navigation(FocusNavigation::Vertical); + ctx.attr_focus_well(); + ctx.focus_on_first_present(); + for (index, names) in [["a", "b"], ["c", "d"]].into_iter().enumerate() { + ctx.next_block_id_mixin(index as u64); + ctx.block_begin("row"); + ctx.attr_display(display); + if matches!(display, Display::Grid) { + ctx.attr_grid_column_subgrid(); + } + ctx.attr_focus_navigation(FocusNavigation::Horizontal); + ctx.inherit_focus(); + for name in names { + ctx.block_begin(name); + ctx.attr_intrinsic_size(Size { width: 2, height: 1 }); + ctx.inherit_focus(); + if consume && ctx.is_focused() && ctx.keyboard_input().is_some() { + ctx.set_input_consumed(); + } + ctx.block_end(); + } + ctx.block_end(); + } + ctx.block_end(); +} + +#[test] +fn grid_navigation_preserves_horizontal_vertical_tab_and_input_priority() { + for display in [Display::Grid, Display::Block] { + let mut tui = Tui::new().unwrap(); + tui.set_size(Size { width: 20, height: 10 }); + focus_grid(&mut tui, display, None, false); + for (key, name, consume) in [ + (vk::RIGHT, "a", true), + (vk::RIGHT, "b", false), + (vk::RIGHT, "a", false), + (vk::LEFT, "b", false), + (vk::DOWN, "c", false), + (vk::UP, "a", false), + (vk::TAB, "b", false), + (SHIFT_TAB, "a", false), + ] { + focus_grid(&mut tui, display, Some(Input::Keyboard(key)), consume); + focus_grid(&mut tui, display, None, false); + assert_eq!(tui.focused_node_path.last(), Some(&node(&tui, name).borrow().id)); + } + } +} + +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_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)))); +}