first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
//! The TUI presenter: turns a root [`TuiView`]'s render output into a painted
|
||||
//! [`TuiFrame`] (a cell [`TuiBuffer`] plus the absolute cursor cell).
|
||||
//!
|
||||
//! Placement: this is the additive `tui` submodule of `presenter` — the TUI
|
||||
//! sibling of the GUI [`Presenter`](crate::Presenter) that owns the rest of
|
||||
//! this module. Both backends genuinely have a presenter (layout + paint of a
|
||||
//! window's view tree); keeping them under one module mirrors that symmetry
|
||||
//! without gating any GUI path.
|
||||
//!
|
||||
//! # Layout pass ordering: measure → arrange → present → paint
|
||||
//!
|
||||
//! 1. **measure** — the root element is measured against a loose
|
||||
//! [`TuiConstraint`] bounded by the target area, returning the size it wants
|
||||
//! (within that box).
|
||||
//! 2. **arrange** — that size is anchored at the area's origin, producing the
|
||||
//! absolute rectangle the root occupies. Container elements recurse this
|
||||
//! measure/arrange internally for their children (the presenter only drives
|
||||
//! the root; the element tree composes itself).
|
||||
//! 3. **present** — the tree is walked via [`TuiElement::present`] to record
|
||||
//! parent/child *view* embeddings (from [`TuiChildView`]-style elements that
|
||||
//! embed a sub-view), which are reported to the core's neutral view
|
||||
//! hierarchy via [`AppContext::report_view_embeddings`]. That hierarchy is
|
||||
//! what the responder chain and focus ancestor propagation walk — for TUI
|
||||
//! views exactly as for GUI views.
|
||||
//! 4. **paint** — the root paints into its arranged rectangle of a fresh
|
||||
//! buffer. Each container paints its children into their sub-rectangles, so
|
||||
//! the whole tree composites into one buffer.
|
||||
//!
|
||||
//! # Child views
|
||||
//!
|
||||
//! A child view is a full [`TuiView`] registered in the app. It is embedded by
|
||||
//! resolving it through the app — [`AppContext::render_tui_view`] renders it to
|
||||
//! its typed boxed element tree — and wrapping that output in a child-view
|
||||
//! element during the *parent* view's render (which has app access). The
|
||||
//! presenter then lays out and paints the composed tree, so the child's output
|
||||
//! lands at exactly the area the layout allocated to it, and the present pass
|
||||
//! records the embedded view as a child of its parent.
|
||||
//!
|
||||
//! [`TuiChildView`]: crate::elements::tui::TuiChildView
|
||||
|
||||
use crate::elements::tui::{
|
||||
TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext, TuiPresentationContext, TuiRect,
|
||||
};
|
||||
use crate::{AppContext, EntityIdMap, TuiView, ViewHandle, WindowId, WindowInvalidation};
|
||||
|
||||
/// A painted frame: the composited cell [`TuiBuffer`] plus the absolute cursor
|
||||
/// position (in buffer cell coordinates), if a focused element owns the cursor.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TuiFrame {
|
||||
/// The composited cell grid, covering the columns/rows up to the painted
|
||||
/// area's right/bottom edge.
|
||||
pub buffer: TuiBuffer,
|
||||
/// The absolute `(x, y)` cell the terminal cursor should occupy, if any.
|
||||
pub cursor: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
impl TuiFrame {
|
||||
/// A blank frame sized to cover `area`, with no cursor.
|
||||
fn blank(area: TuiRect) -> Self {
|
||||
Self {
|
||||
buffer: TuiBuffer::empty(buffer_rect_for(area)),
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out and paints a [`TuiView`]'s element tree into a [`TuiFrame`].
|
||||
///
|
||||
/// Mirrors the GUI [`Presenter`](crate::Presenter) pattern:
|
||||
/// - [`invalidate`](Self::invalidate) re-renders only the views that changed
|
||||
/// into `rendered_views`, leaving unchanged views' cached elements in place.
|
||||
/// - [`present`](Self::present) performs layout (using the `rendered_views` map
|
||||
/// via [`TuiLayoutContext`] so [`TuiChildView`] can find its child without a
|
||||
/// nested render) and paint, then caches the root element in `last_element`
|
||||
/// for event dispatch.
|
||||
///
|
||||
/// [`TuiChildView`]: crate::elements::tui::TuiChildView
|
||||
#[derive(Default)]
|
||||
pub struct TuiPresenter {
|
||||
/// Pre-rendered elements keyed by view id. Populated by [`invalidate`](Self::invalidate)
|
||||
/// for each view that changed; consumed by [`TuiChildView`] during layout.
|
||||
pub(crate) rendered_views: EntityIdMap<Box<dyn TuiElement>>,
|
||||
/// The root element tree from the last [`present`](Self::present) call,
|
||||
/// with all child views already laid out inside it. Reused as the starting
|
||||
/// point for the next frame's layout (for unchanged child subtrees) and for
|
||||
/// event dispatch between frames.
|
||||
pub(crate) last_element: Option<Box<dyn TuiElement>>,
|
||||
}
|
||||
|
||||
impl TuiPresenter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Re-renders the views listed in `invalidation.updated` into `rendered_views`,
|
||||
/// mirroring [`Presenter::invalidate`](crate::Presenter::invalidate).
|
||||
///
|
||||
/// Called by the runtime before each draw so that [`present`] finds
|
||||
/// fresh elements for every changed view and stale-but-valid cached elements
|
||||
/// for everything else.
|
||||
///
|
||||
/// [`present`]: Self::present
|
||||
pub fn invalidate(
|
||||
&mut self,
|
||||
invalidation: &WindowInvalidation,
|
||||
ctx: &AppContext,
|
||||
window_id: WindowId,
|
||||
) {
|
||||
for &view_id in invalidation.updated.difference(&invalidation.removed) {
|
||||
match ctx.render_tui_view(window_id, view_id) {
|
||||
Ok(element) => {
|
||||
self.rendered_views.insert(view_id, element);
|
||||
}
|
||||
Err(e) => log::warn!("TUI view {view_id:?} was not rendered: {e:?}"),
|
||||
}
|
||||
}
|
||||
for &view_id in &invalidation.removed {
|
||||
self.rendered_views.remove(&view_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out and paints the root view's element tree into `area`.
|
||||
///
|
||||
/// The root element is taken from `rendered_views` (if the root was
|
||||
/// re-rendered this frame by [`invalidate`](Self::invalidate)) or from
|
||||
/// `last_element` (the previous frame's root). During layout, a
|
||||
/// [`TuiLayoutContext`] carrying `rendered_views` is threaded through the
|
||||
/// tree so [`TuiChildView`] can retrieve its child element without a nested
|
||||
/// render call. The laid-out root is stored as `last_element` for the next
|
||||
/// frame and for event dispatch.
|
||||
///
|
||||
/// [`TuiChildView`]: crate::elements::tui::TuiChildView
|
||||
pub fn present<V: TuiView>(
|
||||
&mut self,
|
||||
ctx: &mut AppContext,
|
||||
root: &ViewHandle<V>,
|
||||
area: TuiRect,
|
||||
) -> TuiFrame {
|
||||
let window_id = root.window_id(ctx);
|
||||
let root_view_id = root.id();
|
||||
|
||||
// Element resolution order:
|
||||
// 1. Fresh from rendered_views (populated by invalidate() this frame).
|
||||
// 2. Cached last_element — ONLY when rendered_views is non-empty,
|
||||
// meaning invalidate() was called and this view was not changed.
|
||||
// If rendered_views is empty (no invalidate() was called), skip
|
||||
// last_element: the root may be stale (e.g. view called notify()
|
||||
// but the caller drives the presenter standalone without the
|
||||
// runtime's invalidate() step).
|
||||
// 3. Direct render fallback for callers that skip invalidate().
|
||||
let Some(mut element) = self
|
||||
.rendered_views
|
||||
.remove(&root_view_id)
|
||||
.or_else(|| {
|
||||
if !self.rendered_views.is_empty() {
|
||||
self.last_element.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| ctx.render_tui_view(window_id, root_view_id).ok())
|
||||
else {
|
||||
return TuiFrame::blank(area);
|
||||
};
|
||||
|
||||
let mut layout_ctx = TuiLayoutContext {
|
||||
rendered_views: &mut self.rendered_views,
|
||||
};
|
||||
let arranged = arrange(element.as_mut(), area, &mut layout_ctx, ctx);
|
||||
|
||||
let mut embeddings = EntityIdMap::default();
|
||||
{
|
||||
let mut present_ctx = TuiPresentationContext::new(
|
||||
root_view_id,
|
||||
&mut self.rendered_views,
|
||||
&mut embeddings,
|
||||
);
|
||||
element.present(&mut present_ctx);
|
||||
}
|
||||
ctx.report_view_embeddings(window_id, embeddings);
|
||||
|
||||
let frame = paint(element.as_ref(), arranged, area, &mut self.rendered_views);
|
||||
self.last_element = Some(element);
|
||||
frame
|
||||
}
|
||||
|
||||
/// Lays out and paints an already-rendered element tree into `area`.
|
||||
///
|
||||
/// Exposed for the runtime and tests that drive layout/paint for an element
|
||||
/// tree produced outside the app's view registry. No view-ancestry is
|
||||
/// recorded and no `rendered_views` state is consulted or updated.
|
||||
pub fn present_element(
|
||||
&mut self,
|
||||
mut root: Box<dyn TuiElement>,
|
||||
area: TuiRect,
|
||||
app: &AppContext,
|
||||
) -> TuiFrame {
|
||||
let mut empty_views = EntityIdMap::default();
|
||||
let mut layout_ctx = TuiLayoutContext {
|
||||
rendered_views: &mut empty_views,
|
||||
};
|
||||
let arranged = arrange(root.as_mut(), area, &mut layout_ctx, app);
|
||||
paint(root.as_ref(), arranged, area, &mut empty_views)
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the root element from the last
|
||||
/// [`present`](Self::present) call, for use by event dispatch.
|
||||
pub fn last_element_mut(&mut self) -> Option<&mut Box<dyn TuiElement>> {
|
||||
self.last_element.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the root against `area` and anchor the measured size at the area's
|
||||
/// origin (the size is already within the area, but clamp defensively so
|
||||
/// writes stay in bounds).
|
||||
fn arrange(
|
||||
root: &mut dyn TuiElement,
|
||||
area: TuiRect,
|
||||
ctx: &mut TuiLayoutContext,
|
||||
app: &AppContext,
|
||||
) -> TuiRect {
|
||||
let measured = root.layout(TuiConstraint::loose(area.as_size()), ctx, app);
|
||||
TuiRect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
measured.width.min(area.width),
|
||||
measured.height.min(area.height),
|
||||
)
|
||||
}
|
||||
|
||||
/// Composite the tree into a fresh buffer and lift the root-relative cursor
|
||||
/// offset to absolute coordinates. `rendered_views` is threaded through so
|
||||
/// [`TuiChildView`] can look up its child during render and cursor passes.
|
||||
///
|
||||
/// [`TuiChildView`]: crate::elements::tui::TuiChildView
|
||||
fn paint(
|
||||
root: &dyn TuiElement,
|
||||
arranged: TuiRect,
|
||||
area: TuiRect,
|
||||
rendered_views: &mut EntityIdMap<Box<dyn TuiElement>>,
|
||||
) -> TuiFrame {
|
||||
let mut buffer = TuiBuffer::empty(buffer_rect_for(area));
|
||||
let mut ctx = TuiLayoutContext { rendered_views };
|
||||
root.render(arranged, &mut buffer, &mut ctx);
|
||||
|
||||
let cursor = root
|
||||
.cursor_position(arranged, &mut ctx)
|
||||
.map(|(x, y)| (arranged.x.saturating_add(x), arranged.y.saturating_add(y)));
|
||||
|
||||
TuiFrame { buffer, cursor }
|
||||
}
|
||||
|
||||
/// The buffer rect needed to hold everything painted within `area`: it spans
|
||||
/// from the origin to the area's right/bottom edge, so absolute coordinates
|
||||
/// (including any area offset) index correctly.
|
||||
fn buffer_rect_for(area: TuiRect) -> TuiRect {
|
||||
TuiRect::new(0, 0, area.right(), area.bottom())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tui_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Headless tests for [`TuiPresenter`]. They drive layout + paint against local
|
||||
//! [`TuiElement`] test-doubles and assert the composited [`TuiBuffer`] via
|
||||
//! `to_lines` plus the surfaced cursor. The child-view tests register real
|
||||
//! [`TuiView`]s in a test [`App`] and resolve them through the app, exactly as
|
||||
//! the live elements do.
|
||||
|
||||
use super::TuiPresenter;
|
||||
use crate::elements::tui::{
|
||||
TuiBuffer, TuiBufferExt, TuiChildView, TuiConstraint, TuiElement, TuiLayoutContext,
|
||||
TuiPresentationContext, TuiRect, TuiRectExt, TuiSize, TuiStyle,
|
||||
};
|
||||
use crate::platform::WindowStyle;
|
||||
use crate::{
|
||||
AddWindowOptions, App, AppContext, Entity, FocusContext, TuiView, TypedActionView, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
// --- Test-double elements -------------------------------------------------
|
||||
|
||||
/// A single line of text: as wide as its content, one row tall.
|
||||
struct TextDouble {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl TextDouble {
|
||||
fn new(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn width(&self) -> u16 {
|
||||
self.text.chars().count() as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiElement for TextDouble {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: TuiConstraint,
|
||||
_ctx: &mut TuiLayoutContext,
|
||||
_app: &AppContext,
|
||||
) -> TuiSize {
|
||||
constraint.clamp(TuiSize::new(self.width(), 1))
|
||||
}
|
||||
|
||||
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {
|
||||
buffer.set_stringn(
|
||||
area.x,
|
||||
area.y,
|
||||
&self.text,
|
||||
usize::from(area.width),
|
||||
TuiStyle::default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertical stack: each child is laid out at the column's width and stacked
|
||||
/// top-to-bottom. Records per-child sizes at layout time so `render`/
|
||||
/// `cursor_position` (which take `&self`) can place children consistently.
|
||||
struct ColumnDouble {
|
||||
children: Vec<Box<dyn TuiElement>>,
|
||||
child_sizes: Vec<TuiSize>,
|
||||
}
|
||||
|
||||
impl ColumnDouble {
|
||||
fn new(children: Vec<Box<dyn TuiElement>>) -> Self {
|
||||
Self {
|
||||
children,
|
||||
child_sizes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiElement for ColumnDouble {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: TuiConstraint,
|
||||
ctx: &mut TuiLayoutContext,
|
||||
app: &AppContext,
|
||||
) -> TuiSize {
|
||||
self.child_sizes.clear();
|
||||
let mut total_height = 0u16;
|
||||
let mut max_width = 0u16;
|
||||
for child in &mut self.children {
|
||||
let available = TuiSize::new(
|
||||
constraint.max.width,
|
||||
constraint.max.height.saturating_sub(total_height),
|
||||
);
|
||||
let size = child.layout(TuiConstraint::loose(available), ctx, app);
|
||||
total_height = total_height.saturating_add(size.height);
|
||||
max_width = max_width.max(size.width);
|
||||
self.child_sizes.push(size);
|
||||
}
|
||||
constraint.clamp(TuiSize::new(max_width, total_height))
|
||||
}
|
||||
|
||||
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) {
|
||||
let mut remaining = area;
|
||||
for (child, size) in self.children.iter().zip(&self.child_sizes) {
|
||||
let (row, rest) = remaining.split_top(size.height);
|
||||
let child_area = TuiRect::new(row.x, row.y, size.width.min(row.width), row.height);
|
||||
child.render(child_area, buffer, ctx);
|
||||
remaining = rest;
|
||||
}
|
||||
}
|
||||
|
||||
fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) {
|
||||
for child in &mut self.children {
|
||||
child.present(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_position(&self, area: TuiRect, ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> {
|
||||
let mut remaining = area;
|
||||
for (child, size) in self.children.iter().zip(&self.child_sizes) {
|
||||
let (row, rest) = remaining.split_top(size.height);
|
||||
let child_area = TuiRect::new(row.x, row.y, size.width.min(row.width), row.height);
|
||||
if let Some((cx, cy)) = child.cursor_position(child_area, ctx) {
|
||||
return Some((child_area.x - area.x + cx, child_area.y - area.y + cy));
|
||||
}
|
||||
remaining = rest;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A styled container that fills its area with `fill` then paints its child
|
||||
/// inset by `padding` on every side.
|
||||
struct ContainerDouble {
|
||||
child: Box<dyn TuiElement>,
|
||||
padding: u16,
|
||||
fill: char,
|
||||
child_size: TuiSize,
|
||||
}
|
||||
|
||||
impl ContainerDouble {
|
||||
fn new(child: Box<dyn TuiElement>, padding: u16, fill: char) -> Self {
|
||||
Self {
|
||||
child,
|
||||
padding,
|
||||
fill,
|
||||
child_size: TuiSize::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiElement for ContainerDouble {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: TuiConstraint,
|
||||
ctx: &mut TuiLayoutContext,
|
||||
app: &AppContext,
|
||||
) -> TuiSize {
|
||||
let inset = self.padding.saturating_mul(2);
|
||||
let inner_max = TuiSize::new(
|
||||
constraint.max.width.saturating_sub(inset),
|
||||
constraint.max.height.saturating_sub(inset),
|
||||
);
|
||||
let size = self.child.layout(TuiConstraint::loose(inner_max), ctx, app);
|
||||
self.child_size = size;
|
||||
constraint.clamp(TuiSize::new(
|
||||
size.width.saturating_add(inset),
|
||||
size.height.saturating_add(inset),
|
||||
))
|
||||
}
|
||||
|
||||
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) {
|
||||
let fill = self.fill.to_string();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
if let Some(cell) = buffer.cell_mut((x, y)) {
|
||||
cell.set_symbol(&fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
let inner = area.inset(self.padding);
|
||||
let child_area = TuiRect::new(
|
||||
inner.x,
|
||||
inner.y,
|
||||
self.child_size.width.min(inner.width),
|
||||
self.child_size.height.min(inner.height),
|
||||
);
|
||||
self.child.render(child_area, buffer, ctx);
|
||||
}
|
||||
|
||||
fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) {
|
||||
self.child.present(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A leaf that owns the cursor, reporting it at a fixed offset within its area.
|
||||
struct CursorDouble {
|
||||
offset: (u16, u16),
|
||||
}
|
||||
|
||||
impl CursorDouble {
|
||||
fn new(offset: (u16, u16)) -> Self {
|
||||
Self { offset }
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiElement for CursorDouble {
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: TuiConstraint,
|
||||
_ctx: &mut TuiLayoutContext,
|
||||
_app: &AppContext,
|
||||
) -> TuiSize {
|
||||
constraint.clamp(TuiSize::new(5, 1))
|
||||
}
|
||||
|
||||
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {
|
||||
buffer.set_stringn(
|
||||
area.x,
|
||||
area.y,
|
||||
"INPUT",
|
||||
usize::from(area.width),
|
||||
TuiStyle::default(),
|
||||
);
|
||||
}
|
||||
|
||||
fn cursor_position(&self, _area: TuiRect, _ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> {
|
||||
Some(self.offset)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Real views for the child-view recursion tests -------------------------
|
||||
|
||||
fn window_options() -> AddWindowOptions {
|
||||
AddWindowOptions {
|
||||
window_style: WindowStyle::NotStealFocus,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal window root; never presented directly.
|
||||
#[derive(Default)]
|
||||
struct RootStub;
|
||||
|
||||
impl Entity for RootStub {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TuiView for RootStub {
|
||||
fn ui_name() -> &'static str {
|
||||
"RootStub"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn TuiElement> {
|
||||
Box::new(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for RootStub {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
/// A registered child view whose output is a single line of text.
|
||||
struct LeafView;
|
||||
|
||||
impl Entity for LeafView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TuiView for LeafView {
|
||||
fn ui_name() -> &'static str {
|
||||
"LeafView"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn TuiElement> {
|
||||
Box::new(TextDouble::new("CHILD"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A parent view: a header above an embedded child view, which it resolves
|
||||
/// through the app at render time. Records `DescendentFocused` hook firings.
|
||||
struct ParentView {
|
||||
child: ViewHandle<LeafView>,
|
||||
descendent_focus_events: usize,
|
||||
}
|
||||
|
||||
impl Entity for ParentView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TuiView for ParentView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ParentView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn TuiElement> {
|
||||
let child = TuiChildView::new(&self.child);
|
||||
Box::new(ColumnDouble::new(vec![
|
||||
Box::new(TextDouble::new("HEADER")),
|
||||
Box::new(child),
|
||||
]))
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, _ctx: &mut ViewContext<Self>) {
|
||||
if matches!(focus_ctx, FocusContext::DescendentFocused(_)) {
|
||||
self.descendent_focus_events += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn paints_single_root_element_into_area() {
|
||||
App::test((), |app| async move {
|
||||
app.read(|app_ctx| {
|
||||
let mut presenter = TuiPresenter::new();
|
||||
let frame = presenter.present_element(
|
||||
Box::new(TextDouble::new("HELLO")),
|
||||
TuiRect::new(0, 0, 10, 1),
|
||||
app_ctx,
|
||||
);
|
||||
assert_eq!(frame.buffer.to_lines(), vec!["HELLO "]);
|
||||
assert_eq!(frame.cursor, None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composites_nested_container_column_text_with_offsets() {
|
||||
App::test((), |app| async move {
|
||||
app.read(|app_ctx| {
|
||||
let column = ColumnDouble::new(vec![
|
||||
Box::new(TextDouble::new("AB")),
|
||||
Box::new(TextDouble::new("CDE")),
|
||||
]);
|
||||
let container = ContainerDouble::new(Box::new(column), 1, '.');
|
||||
|
||||
let mut presenter = TuiPresenter::new();
|
||||
let frame =
|
||||
presenter.present_element(Box::new(container), TuiRect::new(0, 0, 5, 4), app_ctx);
|
||||
|
||||
assert_eq!(
|
||||
frame.buffer.to_lines(),
|
||||
vec![".....", ".AB..", ".CDE.", "....."],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surfaces_cursor_at_absolute_coordinates() {
|
||||
App::test((), |app| async move {
|
||||
app.read(|app_ctx| {
|
||||
let column = ColumnDouble::new(vec![
|
||||
Box::new(TextDouble::new("HEADER")),
|
||||
Box::new(CursorDouble::new((2, 0))),
|
||||
]);
|
||||
|
||||
let mut presenter = TuiPresenter::new();
|
||||
let frame =
|
||||
presenter.present_element(Box::new(column), TuiRect::new(0, 0, 8, 2), app_ctx);
|
||||
|
||||
// The cursor element sits on row 1 (below the header) at column 2.
|
||||
assert_eq!(frame.cursor, Some((2, 1)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurses_into_registered_child_view_and_reports_embeddings() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, _root) =
|
||||
app.update(|ctx| ctx.add_tui_window(window_options(), |_| RootStub));
|
||||
|
||||
let child = app.update(|ctx| ctx.add_tui_view(window_id, |_| LeafView));
|
||||
let child_view_id = child.id();
|
||||
let parent = app.update(|ctx| {
|
||||
ctx.add_tui_view(window_id, move |_| ParentView {
|
||||
child,
|
||||
descendent_focus_events: 0,
|
||||
})
|
||||
});
|
||||
|
||||
let mut presenter = TuiPresenter::new();
|
||||
let frame = app.update(|ctx| {
|
||||
let invalidation = ctx.take_all_invalidations_for_window(window_id);
|
||||
presenter.invalidate(&invalidation, ctx, window_id);
|
||||
presenter.present(ctx, &parent, TuiRect::new(0, 0, 8, 3))
|
||||
});
|
||||
|
||||
// The child view's output is painted directly below the header, at the
|
||||
// area the layout allocated to the embedded child-view element.
|
||||
assert_eq!(
|
||||
frame.buffer.to_lines(),
|
||||
vec!["HEADER ", "CHILD ", " "],
|
||||
);
|
||||
|
||||
// The presentation pass reported the embedded view into the core's
|
||||
// neutral view hierarchy: the child's ancestor chain now runs through
|
||||
// the parent.
|
||||
assert_eq!(
|
||||
app.read(|ctx| ctx.view_ancestors(window_id, child_view_id)),
|
||||
vec![parent.id(), child_view_id],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focusing_embedded_child_fires_descendent_focus_on_parent() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, _root) =
|
||||
app.update(|ctx| ctx.add_tui_window(window_options(), |_| RootStub));
|
||||
|
||||
let child = app.update(|ctx| ctx.add_tui_view(window_id, |_| LeafView));
|
||||
let child_for_view = child.clone();
|
||||
let parent = app.update(|ctx| {
|
||||
ctx.add_tui_view(window_id, move |_| ParentView {
|
||||
child: child_for_view,
|
||||
descendent_focus_events: 0,
|
||||
})
|
||||
});
|
||||
|
||||
// Before any present pass the core knows nothing about the embedding.
|
||||
assert_eq!(parent.read(&app, |view, _| view.descendent_focus_events), 0);
|
||||
|
||||
// A present pass reports the embedding into `view_parents`...
|
||||
let mut presenter = TuiPresenter::new();
|
||||
app.update(|ctx| {
|
||||
let invalidation = ctx.take_all_invalidations_for_window(window_id);
|
||||
presenter.invalidate(&invalidation, ctx, window_id);
|
||||
presenter.present(ctx, &parent, TuiRect::new(0, 0, 8, 3))
|
||||
});
|
||||
|
||||
// ...so focusing the embedded child walks the ancestor chain and fires
|
||||
// the parent's `on_focus` hook with `DescendentFocused`.
|
||||
child.update(&mut app, |_, ctx| ctx.focus_self());
|
||||
assert_eq!(app.focused_view_id(window_id), Some(child.id()));
|
||||
assert_eq!(parent.read(&app, |view, _| view.descendent_focus_events), 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user