///|
/// 字节出口。cell 投影的产物是 ANSI 文本而不是像素,而
/// `HostSurface::cpu_present()` 的 `PresentTarget` 与 `PixelFrame`
/// 几何(宽/高/row_bytes/scale)绑死,拿它传字节流会逼协议层为文本
/// 造一个假的像素帧。所以出口由 composition root 提供:
/// terminal backend 把它接到 stdout,测试把它接到捕获缓冲。
///
/// 这是对 sun 模板的唯一偏离,`host_surface_metrics_error` 仍然是
/// 共用的 bind 门禁。
pub(all) struct TuiSink {
description : String
write : (String) -> Unit
}
///|
pub fn TuiSink::new(
description~ : String,
write~ : (String) -> Unit,
) -> TuiSink {
{ description, write, }
}
///|
pub fn TuiSink::emit(self : TuiSink, bytes : String) -> Unit {
(self.write)(bytes)
}
///|
pub fn TuiSink::describe(self : TuiSink) -> String {
self.description
}
///|
/// 丢弃所有输出:用于只要结构不要字节的 headless 场景。
pub fn TuiSink::discard() -> TuiSink {
TuiSink::new(description="discard", write=_ => ())
}
///|
/// Terminal ANSI renderer:语义帧 → 等宽 cell 栅格 → ANSI 增量。
///
/// 纯 MoonBit,不 link native 库也不碰 GPU。渲染输入只有
/// `@core.DrawFrame.semantics`,像素命令一律走降级诊断;
/// 量化、归属抢占与盒内折行都留在这个模块里,
/// `moui/core` 与 `moui/runtime` 不知道终端的存在。
pub struct TuiRenderer {
mut metrics : @core.SurfaceMetrics
cell : CellBox
priv canvas : Canvas
text_system : @core.TextSystem
sink : TuiSink
diagnostics : DegradeLog
mut palette : TuiPalette
mut frame_count : Int
mut present_count : Int
mut offscreen_nodes : Int
}
///|
pub fn TuiRenderer::new(
metrics~ : @core.SurfaceMetrics,
sink~ : TuiSink,
cell? : CellBox = CellBox::default(),
diagnostics? : DegradeLog = DegradeLog::new(),
palette? : TuiPalette = TuiPalette::default(),
) -> TuiRenderer {
let canvas = Canvas::new(cell.cols_of(metrics), cell.rows_of(metrics))
{
metrics,
cell,
canvas,
text_system: cell_text_system(cell),
sink,
diagnostics,
palette,
frame_count: 0,
present_count: 0,
offscreen_nodes: 0,
}
}
///|
/// 换调色板并强制下一帧整屏重建:attrs 遍布每一格,增量通路比较的是
/// 「上次发出的格子」,换色后所有格子都算变了。重建走 `Canvas::resize`
/// 同款的全帧标记,由下一次 `render_frame` 发一个 `ESC[2J` 帧。
pub fn TuiRenderer::set_palette(
self : TuiRenderer,
palette : TuiPalette,
) -> Unit {
self.palette = palette
self.canvas.force_rebuild()
}
///|
pub fn TuiRenderer::resize(
self : TuiRenderer,
metrics : @core.SurfaceMetrics,
) -> Unit {
self.metrics = metrics
// 新栅格从空白开始画,残影由 `Canvas::resize` 清掉;这里只保证下一帧
// 的编码走整屏重建。
self.canvas.resize(self.cell.cols_of(metrics), self.cell.rows_of(metrics))
}
///|
pub fn TuiRenderer::cols(self : TuiRenderer) -> Int {
self.canvas.cols
}
///|
pub fn TuiRenderer::rows(self : TuiRenderer) -> Int {
self.canvas.rows
}
///|
/// 投影后的第 `row` 行可见文本,用于结构断言而不是字节比对。
pub fn TuiRenderer::line_text(self : TuiRenderer, row : Int) -> String {
self.canvas.screen.line_at(row).text()
}
///|
pub fn TuiRenderer::diagnostics(self : TuiRenderer) -> DegradeLog {
self.diagnostics
}
///|
/// 最后一帧被丢掉的离屏语义节点数。
pub fn TuiRenderer::offscreen_node_count(self : TuiRenderer) -> Int {
self.offscreen_nodes
}
///|
pub fn TuiRenderer::frame_count(self : TuiRenderer) -> Int {
self.frame_count
}
///|
pub fn TuiRenderer::present_count(self : TuiRenderer) -> Int {
self.present_count
}
///|
pub fn TuiRenderer::text_system(self : TuiRenderer) -> @core.TextSystem {
self.text_system
}
///|
/// 渲染一帧:投影语义树、登记被丢弃的像素命令、只输出脏行增量。
///
/// 零增量帧返回 `presented=false` 且不写一个字节。这不是失败,
/// 而是增量通路生效的证据:内容没变就不该重画终端。
pub fn TuiRenderer::render_frame(
self : TuiRenderer,
frame : @core.DrawFrame,
) -> @render.RenderFrameResult {
self.frame_count = self.frame_count + 1
self.canvas.resize(
self.cell.cols_of(self.metrics),
self.cell.rows_of(self.metrics),
)
self.diagnostics.clear()
self.canvas.begin_frame()
self.offscreen_nodes = 0
match frame.semantics {
Some(payload) => {
let offscreen = project_payload(
self.canvas,
payload,
self.cell,
self.diagnostics,
self.palette,
)
self.offscreen_nodes = offscreen
if offscreen > 0 {
self.diagnostics.note(
"offscreen-nodes",
"\{offscreen} 个语义节点完全在可视区之外,已按投影规则丢掉而不是夹到边界",
)
}
}
None => {
self.offscreen_nodes = 0
self.diagnostics.note(
"missing-semantics", "帧没有语义负载,TUI 渲染器无内容可投影;需要 AppRuntime::set_semantics_frames_enabled(true)",
)
}
}
note_dropped_commands(self.diagnostics, frame.commands, frame.platform_views)
let bytes = self.canvas.present_update()
let presented = bytes.length() > 0
if presented {
self.sink.emit(bytes)
self.present_count = self.present_count + 1
}
@render.RenderFrameResult::new(presented~, queued=false)
}