上一节我们梳理了 Ratatui Kit 的组件化实现,包括组件协议、Props 的类型擦除与管理,以及组件的动态实例化机制。本节将进一步介绍如何渲染组件树,并构建一个能够自动分配区域的布局系统。
为 InstantiatedComponent
实现 draw
方法:
pub fn draw(&self, frame: &mut ratatui::Frame<'_>, area: ratatui::layout::Rect) {
self.component.draw(frame, area);
for child in self.children.iter() {
child.draw(frame, area);
}
}
可以看到,渲染组件实例的过程非常直接:只需递归调用每个组件的 draw
方法即可。不过,目前所有组件都绘制在同一个 area 区域,这显然无法满足实际 UI 的需求。接下来,我们需要引入布局系统,实现对子组件区域的自动划分和分配。
|> 思考: 如何实现布局系统?
我们可以借鉴 Ratatui 的布局机制,利用 ratatui::layout
模块来为组件分配区域。Ratatui 的 Layout
本质上是一种 Flex 布局,类似于 Web 前端的 flexbox。要实现自动布局,核心在于获取每个组件的尺寸和排列方式,并据此动态划分和分配区域。
在实现自动布局系统时,我们需要一种方式来描述每个组件的排列方式、尺寸约束和空间分配规则。这就像 Web 前端的 flexbox 布局一样,每个元素都可以声明自己的布局属性,由布局引擎自动计算和分配空间。
为此,我们可以为每个组件定义一套“布局样式”(LayoutStyle),用来统一描述其在父容器中的排列、对齐、间距、尺寸等信息。这样,组件树在渲染时就能根据这些样式自动完成区域划分和嵌套布局。
布局样式的核心作用包括:
我们可以在 render/layout_style.rs
中定义如下结构体:
use ratatui::layout::{Constraint, Direction, Flex, Layout, Margin, Offset};
/// 用于描述组件布局样式的结构体,类似于 Web 的 Flex 布局属性
#[derive(Default)]
pub struct LayoutStyle {
/// 主轴方向(横向/纵向)
pub flex_direction: Direction,
/// 主轴对齐方式(如 Start, End, Center, SpaceBetween 等)
pub justify_content: Flex,
/// 子项间距
pub gap: i32,
/// 外边距
pub margin: Margin,
/// 偏移量
pub offset: Offset,
/// 宽度约束
pub width: Constraint,
/// 高度约束
pub height: Constraint,
}
通过为每个组件声明 LayoutStyle,我们就能像搭积木一样灵活组合 UI 结构,而不用手动计算每个区域的位置和大小。
此外,我们还可以为 LayoutStyle 提供一些常用方法,方便在布局计算和渲染时使用:
impl LayoutStyle {
/// 根据当前样式生成 Ratatui 的 Layout 对象
pub fn get_layout(&self) -> Layout {
Layout::default()
.direction(self.flex_direction)
.flex(self.justify_content)
.spacing(self.gap)
}
/// 获取宽度约束
pub fn get_width(&self) -> Constraint {
self.width
}
/// 获取高度约束
pub fn get_height(&self) -> Constraint {
self.height
}
/// 计算应用 margin 和 offset 后的内部区域
pub fn inner_area(&self, area: ratatui::layout::Rect) -> ratatui::layout::Rect {
area.offset(self.offset).inner(self.margin)
}
}
有了布局样式的基础,我们就可以实现自动布局计算,让每个组件根据自身和子组件的布局属性,自动完成区域划分和嵌套渲染。
首先,在 InstantiatedComponent
结构体中添加一个 layout_style
字段,用于保存当前组件的布局样式:
pub struct InstantiatedComponent {
component: Box<dyn AnyComponent>,
children: Components,
helper: Box<dyn ComponentHelperExt>,
layout_style: LayoutStyle,
}
impl InstantiatedComponent{
pub fn new(
mut props: AnyProps,
helper: Box<dyn ComponentHelperExt>,
layout_style: LayoutStyle,
children: Components,
) -> Self {
let component = helper.new_component(props.borrow());
Self {
component,
children,
helper,
layout_style,
}
}
// ...
}
然后为 Components
添加一个方法,用于获取所有子组件在某个方向上的布局约束:
impl Components {
/// 根据给定方向,收集所有子组件在该方向上的布局约束(Constraint)
///
/// - 如果方向为 Horizontal,则收集每个子组件的宽度约束
/// - 如果方向为 Vertical,则收集每个子组件的高度约束
///
/// 这些约束用于 Ratatui 布局系统自动分配空间
pub fn get_constraints(&self, direction: Direction) -> Vec<Constraint> {
self.components
.iter()
.map(|c| match direction {
Direction::Horizontal => c.layout_style.get_width(),
Direction::Vertical => c.layout_style.get_height(),
})
.collect()
}
}
最后,修改 draw
方法,结合布局样式和约束,实现自动区域划分和递归渲染:
/// 渲染当前组件及其子组件,自动进行布局区域划分
///
/// 1. 先根据自身 layout_style 计算出当前组件的实际绘制区域(应用 margin/offset)
/// 2. 绘制当前组件内容
/// 3. 根据主轴方向,获取所有子组件的布局约束,生成主布局
/// 4. 将主区域划分为多个子区域
/// 5. 对每个子区域再按交叉轴方向进一步细分,实现嵌套布局
/// 6. 递归调用每个子组件的 draw 方法,传入对应的区域
pub fn draw(&self, frame: &mut ratatui::Frame<'_>, area: ratatui::layout::Rect) {
let layout_style = &self.layout_style;
// 1. 计算应用 margin/offset 后的实际区域
let area = layout_style.inner_area(area);
// 2. 绘制当前组件内容
self.component.draw(frame, area);
// 3. 构建主布局,按主轴方向分配子区域
let layout = layout_style
.get_layout()
.constraints(self.children.get_constraints(layout_style.flex_direction));
let areas = layout.split(area);
let mut children_areas: Vec<ratatui::prelude::Rect> = vec![];
// 4. 计算交叉轴方向(主轴为横则交叉轴为纵,反之亦然)
let rev_direction = match layout_style.flex_direction {
Direction::Horizontal => Direction::Vertical,
Direction::Vertical => Direction::Horizontal,
};
// 5. 对每个主区域再按交叉轴方向细分,实现嵌套布局
for (area, constraint) in areas
.iter()
.zip(self.children.get_constraints(rev_direction))
{
let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
children_areas.push(area);
}
// 6. 递归渲染所有子组件
for (child, child_area) in self.children.iter().zip(children_areas) {
child.draw(frame, child_area);
}
}
通过这种方式,我们实现了一个灵活的自动布局系统。每个组件只需声明自己的布局样式,组件树即可实现自动的区域划分和递归渲染。
|> 思考: 如果父节点在渲染时添加了边框,子组件实际获得的 area 会发生什么变化?
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().blue());
let inner_area = block.inner(area);
当前的实现中,构建子组件的区域等于父级节点的渲染区域,而实际的渲染区域可能因为边框这种情况,会导致子组件的区域计算不准确。为了解决这个问题,我们需要重构Component
trait。
在实际 UI 渲染中,父组件经常会绘制边框、标题等装饰,这会导致其“可用区域”小于原始分配的 area。如果子组件直接继承父组件的 area,内容就可能被边框遮挡或布局错位。
为了解决这个问题,我们需要对组件的绘制协议进行重构。核心思路是:为每个组件引入一个专门的“渲染上下文”,让组件能够安全、准确地获取和操作自己的绘制区域。
下面是具体实现步骤:
首先,新建render/drawer.rs
文件,定义一个渲染上下文(ComponentDrawer
),用于处理组件的绘制和区域计算。它封装了 frame 和当前组件的 area,确保每个组件都能安全地操作自己的绘制区域。
/// 用于封装组件绘制上下文,便于在组件内部安全地操作 frame 和区域
pub struct ComponentDrawer<'a, 'b: 'a> {
/// 当前组件的绘制区域
pub area: ratatui::layout::Rect,
/// 指向全局 frame 的可变引用
pub frame: &'a mut ratatui::Frame<'b>,
}
接着,为其实现一些辅助方法,方便组件内部操作 buffer 和渲染 widget:
impl<'a, 'b> ComponentDrawer<'a, 'b> {
/// 创建新的 ComponentDrawer
pub fn new(frame: &'a mut ratatui::Frame<'b>, area: ratatui::layout::Rect) -> Self {
Self { area, frame }
}
/// 获取底层 buffer 的可变引用
pub fn buffer_mut(&mut self) -> &mut ratatui::buffer::Buffer {
self.frame.buffer_mut()
}
pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) {
widget.render(area, self.buffer_mut());
}
}
然后,修改 Component
trait,使其使用 ComponentDrawer
进行绘制。这样每个组件都能通过 drawer 获取自己的可用区域和 frame,保证渲染的准确性和安全性。
pub trait Component: Any {
type Props<'a>
where
Self: 'a;
fn new(props: &Self::Props<'_>) -> Self;
/// 使用 ComponentDrawer 进行绘制
fn draw(&self, drawer: &mut ComponentDrawer<'_, '_>);
}
同理也需要修改 AnyComponent
trait,这里就不再赘述。
最后,在 InstantiatedComponent
的 draw
方法中,递归传递和更新 drawer 的 area,确保每个子组件都能获得准确的可用区域。这样,父组件即使有边框、标题等装饰,也不会影响子组件的内容布局。
pub fn draw(&self, drawer: &mut ComponentDrawer) {
let layout_style = &self.layout_style;
// 1. 计算应用 margin/offset 后的实际区域
let area = layout_style.inner_area(drawer.area);
drawer.area = area;
// 2. 绘制当前组件内容
self.component.draw(drawer);
// 3. 构建主布局,按主轴方向分配子区域
let layout = layout_style
.get_layout()
.constraints(self.children.get_constraints(layout_style.flex_direction));
let areas = layout.split(area);
let mut children_areas: Vec<ratatui::prelude::Rect> = vec![];
// 4. 计算交叉轴方向(主轴为横则交叉轴为纵,反之亦然)
let rev_direction = match layout_style.flex_direction {
Direction::Horizontal => Direction::Vertical,
Direction::Vertical => Direction::Horizontal,
};
// 5. 对每个主区域再按交叉轴方向细分,实现嵌套布局
for (area, constraint) in areas
.iter()
.zip(self.children.get_constraints(rev_direction))
{
let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
children_areas.push(area);
}
// 6. 递归渲染所有子组件
for (child, child_area) in self.children.iter().zip(children_areas) {
drawer.area = child_area;
child.draw(drawer);
}
}
通过这种方式,组件的渲染区域管理更加清晰和安全,UI 结构也更加健壮和易于维护。
在实际 UI 开发中,不同类型的组件往往有着各自独特的布局需求。例如,有些组件可能需要将子组件横向等分排列,有些则需要纵向堆叠,甚至有些特殊组件(如表格、网格等)需要自定义更复杂的区域划分方式。如果所有组件都采用同一种固定的子区域划分逻辑,显然无法满足多样化的布局场景。因此,我们需要为组件系统提供一种“可扩展的子区域划分机制”,让每个组件能够根据自身的需求灵活决定其子组件的布局方式。
为此,我们在 Component
trait 中引入了一个新的方法 calc_children_areas
,用于抽象和定制子组件的区域划分逻辑。该方法的默认实现采用类似 flexbox 的自动布局策略:
// 默认使用flex布局计算子组件的area
fn calc_children_areas(
&self,
children: &Components,
layout_style: &LayoutStyle,
drawer: &mut ComponentDrawer<'_, '_>,
) -> Vec<ratatui::prelude::Rect> {
let layout = layout_style
.get_layout()
.constraints(children.get_constraints(layout_style.flex_direction));
let areas = layout.split(drawer.area);
let mut children_areas: Vec<ratatui::prelude::Rect> = vec![];
let rev_direction = match layout_style.flex_direction {
Direction::Horizontal => Direction::Vertical,
Direction::Vertical => Direction::Horizontal,
};
for (area, constraint) in areas.iter().zip(children.get_constraints(rev_direction)) {
let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
children_areas.push(area);
}
children_areas
}
然后修改 AnyComponent
trait,添加calc_children_areas
方法:
pub trait AnyComponent {
// ... 其他方法保持不变
fn calc_children_areas(
&self,
children: &Components,
layout_style: &LayoutStyle,
drawer: &mut ComponentDrawer<'_, '_>,
) -> Vec<ratatui::prelude::Rect>;
}
impl<T> AnyComponent for T
where
T: Component,
{
// ... 其他方法保持不变
fn calc_children_areas(
&self,
children: &Components,
layout_style: &LayoutStyle,
drawer: &mut ComponentDrawer<'_, '_>,
) -> Vec<ratatui::prelude::Rect> {
Component::calc_children_areas(self, children, layout_style, drawer)
}
}
这样一来,无论是框架内置组件还是用户自定义组件,都可以通过实现或重写 calc_children_areas
,灵活控制子组件的区域划分方式。
在实际渲染过程中,我们只需在 InstantiatedComponent
的 draw
方法中调用 calc_children_areas
,即可获取每个子组件的专属区域,并递归完成整个组件树的布局与绘制:
pub fn draw(&self, drawer: &mut ComponentDrawer) {
let layout_style = &self.layout_style;
// 1. 计算应用 margin/offset 后的实际区域
let area = layout_style.inner_area(drawer.area);
drawer.area = area;
// 2. 绘制当前组件内容
self.component.draw(drawer);
// 3. 计算所有子组件的区域划分
let children_areas =
self.component
.calc_children_areas(&self.children, layout_style, drawer);
// 4. 递归渲染所有子组件
for (child, child_area) in self.children.iter().zip(children_areas) {
drawer.area = child_area;
child.draw(drawer);
}
}
通过这种方式,我们实现了一个灵活的子组件区域划分机制。每个组件可以根据自身的布局样式和子组件的需求,自动计算出合适的区域分配方式。
在组件化 UI 框架中,渲染循环是驱动界面更新的核心机制。为了让整个组件树能够高效、统一地响应用户输入和状态变化,我们需要设计一个“根节点”来负责组件树的生命周期管理、渲染上下文的初始化,以及事件循环的调度。
为此,我们在 render/tree.rs
中定义了一个 Tree
结构体,作为组件树的根节点。它不仅持有整个 UI 的根组件,还负责初始化终端渲染环境、分发渲染上下文,并统一处理事件流。这样,所有的渲染和事件响应都可以在同一个主循环中有序进行。
具体实现如下:
pub struct Tree {
root_component: InstantiatedComponent,
}
impl Tree {
/// 创建组件树根节点
pub fn new(root_component: InstantiatedComponent) -> Self {
Self { root_component }
}
/// 单次渲染:初始化渲染上下文并递归渲染组件树
pub fn render(&self, terminal: &mut ratatui::DefaultTerminal) -> io::Result<()> {
terminal.draw(|frame| {
let area = frame.area();
let mut drawer = ComponentDrawer::new(frame, area);
self.root_component.draw(&mut drawer);
})?;
Ok(())
}
/// 主渲染循环:持续监听事件并驱动组件树渲染
pub async fn render_loop(&self) -> io::Result<()> {
let mut terminal = ratatui::init();
let mut event_stream = EventStream::new();
loop {
// 每帧刷新 UI
self.render(&mut terminal)?;
// 监听并处理用户输入事件
if let Some(Ok(event)) = event_stream.next().await {
if let Event::Key(key) = event {
match key.code {
KeyCode::Char('q') => break, // 按 q 退出
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
break; // Ctrl+C 退出
}
_ => {}
}
}
}
}
ratatui::restore();
Ok(())
}
}
通过这种设计,Tree
结构体将组件树的渲染与事件循环解耦,极大提升了系统的可维护性和扩展性。无论是后续添加全局状态管理、热重载、还是复杂的输入交互,都可以在这个统一的主循环中灵活实现。
我们将用这个新的UI框架来重新实现经典的计数器应用。
下面是基于新架构的 main.rs
计数器应用核心代码:
// 引入 ratatui 相关模块
use ratatui::{
layout::{Constraint, Direction},
style::{Style, Stylize},
widgets::{Block, Paragraph},
};
// 引入 ratatui-kit-principle 组件系统相关模块
use ratatui_kit_principle::{
component::{
Component,
component_helper::ComponentHelper,
instantiated_component::{Components, InstantiatedComponent},
},
props::AnyProps,
render::{drawer::ComponentDrawer, layout_style::LayoutStyle, tree::Tree},
};
use std::io;
// 文本组件,负责渲染一段文本
pub struct Text {
pub text: String,
pub style: Style,
pub alignment: ratatui::layout::Alignment,
}
// 文本组件的 Props
pub struct TextProps<'a> {
pub text: &'a str,
pub style: Style,
pub alignment: ratatui::layout::Alignment,
}
// Text 组件实现 Component 协议
impl Component for Text {
type Props<'a> = TextProps<'a>;
fn new(props: &Self::Props<'_>) -> Self {
Self {
text: props.text.to_string(),
style: props.style,
alignment: props.alignment,
}
}
fn draw(&self, drawer: &mut ComponentDrawer<'_, '_>) {
// 渲染段落文本
let paragraph = Paragraph::new(self.text.clone())
.style(self.style)
.alignment(self.alignment);
drawer.render_widget(paragraph, drawer.area);
}
}
// 边框组件,负责为内容添加边框
pub struct Border {
pub border_style: Style,
}
// Border 组件实现 Component 协议
impl Component for Border {
type Props<'a> = Style;
fn new(props: &Self::Props<'_>) -> Self {
Self {
border_style: props.clone(),
}
}
fn draw(&self, drawer: &mut ComponentDrawer<'_, '_>) {
// 绘制带样式的边框
let block = Block::bordered().border_style(self.border_style);
let inner_area = block.inner(drawer.area);
drawer.render_widget(block, drawer.area);
// 更新 drawer 的可用区域为边框内部
drawer.area = inner_area;
}
}
// 主程序入口,构建组件树并启动渲染循环
#[tokio::main]
async fn main() -> io::Result<()> {
let count = 0;
// 构建根组件(带边框的容器),并嵌套多个子组件
let instantiated_component = InstantiatedComponent::new(
AnyProps::owned(Style::default().blue()),
ComponentHelper::<Border>::boxed(),
LayoutStyle {
gap: 3,
flex_direction: Direction::Vertical,
..Default::default()
},
Components {
components: vec![
// 标题文本
InstantiatedComponent::new(
AnyProps::owned(TextProps {
text: "Welcome to the Counter App",
style: Style::default().bold().light_blue(),
alignment: ratatui::layout::Alignment::Center,
}),
ComponentHelper::<Text>::boxed(),
LayoutStyle {
height: Constraint::Length(1),
..Default::default()
},
Components::default(),
),
// 计数显示
InstantiatedComponent::new(
AnyProps::owned(TextProps {
text: &format!("Count: {}", count),
style: Style::default().light_green(),
alignment: ratatui::layout::Alignment::Center,
}),
ComponentHelper::<Text>::boxed(),
LayoutStyle {
height: Constraint::Fill(1),
..Default::default()
},
Components::default(),
),
// 操作提示
InstantiatedComponent::new(
AnyProps::owned(TextProps {
text: "Press q or Ctrl+C to quit, + to increase, - to decrease",
style: Style::default().yellow(),
alignment: ratatui::layout::Alignment::Center,
}),
ComponentHelper::<Text>::boxed(),
LayoutStyle {
height: Constraint::Length(1),
..Default::default()
},
Components::default(),
),
],
},
);
// 启动组件树的渲染主循环
Tree::new(instantiated_component).render_loop().await?;
Ok(())
}
运行后,你会看到如下效果:
你还可以尝试注释掉 Border
组件中 drawer.area = inner_area;
这一行,观察子组件区域分配的变化:
通过这种方式,我们实现了一个完整的组件化 UI 框架,支持自动布局、动态区域划分和灵活的组件组合。
本节详细介绍了 Ratatui Kit 的渲染与布局机制,包括组件递归渲染、布局样式的声明、自动布局计算和子组件区域划分的抽象方法,并展示了如何通过统一的渲染主循环管理 UI 结构。这些设计让终端 UI 的开发变得更加高效和灵活,也为后续实现更复杂的界面和交互提供了良好的基础。
当前实现仍有改进空间:
下一步将引入 hook 系统,增强组件的状态管理和响应能力,进一步提升开发体验。