///|
/// 语义节点的可见文本:label 优先,其次 value,最后 description。
pub fn node_text(node : @core.SemanticsNode) -> String {
if node.label.length() > 0 {
node.label
} else if node.value.length() > 0 {
node.value
} else {
node.description
}
}
///|
fn clamp_cell(value : Int, limit : Int) -> Int {
if value < 0 {
0
} else if value >= limit {
limit - 1
} else {
value
}
}
///|
/// 一维量化:只保留「格中心落在 [edge, edge+span) 内」的那些格。
///
/// 终端没有半格,而宿主发出的点击就是格中心那一个点
/// (`y = (row + 0.5) * cell`),所以一格算不算这一块的一部分,判据只能是它的
/// 中心在不在帧里。用外扩规则(顶边 `floor`、底边 `ceil`)时,帧顶边一落到格子的
/// 下半格,画出来的那一行中心其实在帧上方 —— 看得见却点不着,按钮标签会整体比
/// 命中区高一行。
fn quantize_span(
edge : Double,
span : Double,
cell : Double,
limit : Int,
) -> (Int, Int)? {
let lo = (edge / cell - 0.5).ceil().to_int()
let hi = ((edge + span) / cell - 0.5).ceil().to_int() - 1
if hi < lo {
None
} else {
Some((clamp_cell(lo, limit), clamp_cell(hi, limit)))
}
}
///|
/// 量化成绘制区间。帧窄于半格时(分隔线就是这种)可能一个中心都不落在里面,
/// 那时退回帧起点那一格:宁可画出来少一行可点,也不能整块消失。
fn quantize_axis(
edge : Double,
span : Double,
cell : Double,
limit : Int,
) -> (Int, Int) {
match quantize_span(edge, span, cell, limit) {
Some(pair) => pair
None => {
let only = clamp_cell((edge / cell).floor().to_int(), limit)
(only, only)
}
}
}
///|
/// 量化成绘制盒。子帧 ⊆ 父帧,所以按中心取格不会让分栏边界散架。
pub fn quantize(
frame : @core.Rect,
cell : CellBox,
cols : Int,
rows : Int,
) -> (Int, Int, Int, Int) {
let (r0, r1) = quantize_axis(
frame.origin.y,
frame.size.height,
cell.height,
rows,
)
let (c0, c1) = quantize_axis(
frame.origin.x,
frame.size.width,
cell.width,
cols,
)
(r0, c0, r1, c1)
}
///|
/// 高亮带专用的量化:只用「中心确实落在矩形里」的那些格,没有残格兜底。
///
/// 残格兜底是为分隔线那种零高帧留的,让画不出字的窄帧至少占一格;但反白带
/// 铺在一格中心都不属于它的格子上,用户看到的就是一个没有内容的色块,
/// 而且那一格点不回这个条目(实测 100x30 末行只剩 4px 时整条底边框被反白盖住)。
fn quantize_box(
frame : @core.Rect,
cell : CellBox,
cols : Int,
rows : Int,
) -> (Int, Int, Int, Int)? {
match quantize_span(frame.origin.y, frame.size.height, cell.height, rows) {
None => None
Some((r0, r1)) =>
match quantize_span(frame.origin.x, frame.size.width, cell.width, cols) {
None => None
Some((c0, c1)) => Some((r0, c0, r1, c1))
}
}
}
///|
fn index_of(
nodes : Array[@core.SemanticsNode],
id : @core.SemanticsNodeId,
) -> Int? {
let mut i = 0
while i < nodes.length() {
if nodes[i].node_id == id {
return Some(i)
}
i = i + 1
}
None
}
///|
/// 一个节点能被画到的最大范围。根节点用它起步,
/// 深层节点只会继承更窄的范围。
pub fn viewport(cell : CellBox, cols : Int, rows : Int) -> @core.Rect {
@core.Rect::new(
x=0.0,
y=0.0,
width=cell.width * cols.to_double(),
height=cell.height * rows.to_double(),
)
}
///|
/// 继承范围要分两条,因为终端的「线」是一整格,而布局的边框只有几像素。
///
/// - `chrome`:区域画边框可用的范围。`List`/`Tree` 的帧和它所属的
/// `ScrollView` 重合,两道框必须还能落在同一圈格子上,否则会套出第二道框。
/// - `content`:字形与高亮带可用的范围,也就是那一圈边框的**内侧**。
/// 边框量化要吃掉一整格,内容却按像素贴着框的内沿起排,两者共用一格时
/// 列表行就写在框线上(实测 100x30:树行首格压在顶边框,末行残格压在底边框)。
pub struct Clip {
chrome : @core.Rect
content : @core.Rect
}
///|
/// 框线内侧的像素范围。框小到没有内侧时退回整栏:
/// 一栏放不下任何行也要先把行画出来,不能只剩一个空框。
fn inside_ring(
ring : @core.Rect,
cell : CellBox,
cols : Int,
rows : Int,
) -> @core.Rect {
let (r0, c0, r1, c1) = quantize(ring, cell, cols, rows)
let (top, left) = (r0 + 1, c0 + 1)
let (bottom, right) = (r1 - 1, c1 - 1)
if bottom < top || right < left {
ring
} else {
@core.Rect::new(
x=cell.width * left.to_double(),
y=cell.height * top.to_double(),
width=cell.width * (right - left + 1).to_double(),
height=cell.height * (bottom - top + 1).to_double(),
)
}
}
///|
/// 子树的可见范围:只有 `ScrollView` 会收窄范围,
/// 其它容器(分组、行、面板边框)不裁剪,否则一个普通 `Group`
/// 就能把兄弟节点的格子擦掉。
fn child_clip(
node : @core.SemanticsNode,
inherited : Clip,
cell : CellBox,
cols : Int,
rows : Int,
) -> Clip {
if node.role != @core.ScrollView {
inherited
} else {
match visible_box(node.frame, inherited.chrome) {
None =>
// 滚动面板本身已完全离屏:给它一个空范围,子节点全部按离屏处理。
{
chrome: @core.Rect::new(x=0.0, y=0.0, width=0.0, height=0.0),
content: @core.Rect::new(x=0.0, y=0.0, width=0.0, height=0.0),
}
Some(ring) =>
{ chrome: ring, content: inside_ring(ring, cell, cols, rows), }
}
}
}
///|
/// 继承范围按广度优先序逐层收窄,与 `paint_order` 同一条通路。
/// 树里指不到或来自环之外的节点拿可视区,和「补在尾部」的规则一致。
pub fn clips_in_tree(
nodes : Array[@core.SemanticsNode],
root : @core.SemanticsNodeId?,
cell : CellBox,
cols : Int,
rows : Int,
) -> Array[Clip] {
let view = viewport(cell, cols, rows)
let start = { chrome: view, content: view, }
let clips = Array::make(nodes.length(), start)
let visited = Array::make(nodes.length(), false)
let queue : Array[Int] = []
let queue_clips : Array[Clip] = []
match root {
Some(id) =>
match index_of(nodes, id) {
Some(i) => {
queue.push(i)
queue_clips.push(start)
}
None => ()
}
None => ()
}
let mut head = 0
while head < queue.length() {
let i = queue[head]
let clip = queue_clips[head]
head = head + 1
if visited[i] {
continue
}
visited[i] = true
clips[i] = clip
let child = child_clip(nodes[i], clip, cell, cols, rows)
for child_id in nodes[i].children {
match index_of(nodes, child_id) {
Some(j) =>
if !visited[j] {
queue.push(j)
queue_clips.push(child)
}
None => ()
}
}
}
clips
}
///|
/// 节点的绘制盒与它的可视范围求交。
///
/// 退化盒(高或宽为 0,例如分隔线)与范围的交集按定义为空,
/// 但它可能正好落在范围里,所以用左上角判归属,而不是当成离屏丢掉。
fn visible_box(frame : @core.Rect, clip : @core.Rect) -> @core.Rect? {
match frame.intersection(clip) {
Some(r) => Some(r)
None =>
if frame.is_empty() && clip.contains(frame.origin) {
Some(frame)
} else {
None
}
}
}
///|
/// 从根开始的广度优先序:浅层先画,深层后可以抢占浅层的格子。
/// 这就是 S0 定下的 z-order 策略。游离于根之外的节点补在尾部,
/// 保证不会因为树不完整而整个丢掉。
pub fn paint_order(
nodes : Array[@core.SemanticsNode],
root : @core.SemanticsNodeId?,
) -> Array[Int] {
let order : Array[Int] = []
let visited = Array::make(nodes.length(), false)
let queue : Array[Int] = []
match root {
Some(id) =>
match index_of(nodes, id) {
Some(i) => queue.push(i)
None => ()
}
None => ()
}
let mut head = 0
while head < queue.length() {
let i = queue[head]
head = head + 1
if visited[i] {
continue
}
visited[i] = true
order.push(i)
for child_id in nodes[i].children {
match index_of(nodes, child_id) {
Some(j) => if !visited[j] { queue.push(j) }
None => ()
}
}
}
let mut i = 0
while i < nodes.length() {
if !visited[i] {
order.push(i)
}
i = i + 1
}
order
}
///|
fn check_mark(checked : @core.SemanticsCheckedState?) -> String {
match checked {
Some(@core.Checked) => "(x) "
Some(@core.Mixed) => "(-) "
Some(@core.Unchecked) | None => "( ) "
}
}
///|
/// 有内容的节点怎么写字。`region`/`item` 不进这里:前者只画框,
/// 后者把自己整行的字都交给了子节点。
fn paint_content(
canvas : Canvas,
log : DegradeLog,
node : @core.SemanticsNode,
role : String,
text : String,
attrs : @tui.CellAttrs,
box : (Int, Int, Int, Int),
seq : Int,
) -> Unit {
let (r0, c0, r1, c1) = box
if role == "Button" {
// 没名字的按钮不硬凑 `[]`:那是包装层,内容在子节点里,
// 空括号只会盖住容器的角。
if text.length() > 0 {
canvas.write_boxed(r0, c0, r1, c1, "[" + text + "]", attrs, seq)
}
} else if role == "Image" || role == "WebView" {
let source = if role == "Image" { "role-image" } else { "role-web-view" }
let marker = if role == "Image" { "[image]" } else { "[web-view]" }
log.note(
source,
"\{role} 语义节点只能占位,像素内容不可投影",
)
canvas.write_boxed(r0, c0, r1, c1, marker, attrs, seq)
} else if role == "Checkbox" || role == "Switch" || role == "Radio" {
canvas.write_boxed(
r0,
c0,
r1,
c1,
check_mark(node.state.checked) + text,
attrs,
seq,
)
} else if role == "TextField" || role == "ComboBox" {
canvas.write_boxed(r0, c0, r1, c1, text + "|", attrs, seq)
} else if role == "Slider" || role == "Progress" {
let mut c = c0
while c <= c1 && c < canvas.cols {
ignore(canvas.put(r0, c, "═", attrs, seq))
c = c + 1
}
ignore(canvas.put(r0, c0 + 1, ">", attrs, seq))
} else if text.length() > 0 {
canvas.write_boxed(r0, c0, r1, c1, text, attrs, seq)
} else if node.children.length() == 0 && role_kind(node.role) == Content {
log.note(
"unlabeled-leaf",
"无名叶子 \{role} 落在格 \{r0},\{c0}:只在不抢已有笔画的前提下写一个 `?`",
)
// 占位符是标记不是内容,所以用最低优先级:它只填还没人占的格子。
ignore(canvas.put(r0, c0, "?", attrs, canvas.new_seq(0)))
}
}
///|
/// 绘制一个语义节点,返回它的绘制范围(完全落在可用范围之外时返回 `None`)。
///
/// 求交对象按角色分两条:区域的边框用 `chrome`,其余写字的节点用 `content`
/// 那一圈框的内侧。滚出面板的行因此被丢掉而不是叠到面板外的兄弟行上,
/// 这正是 S4 双栏 demo 暴露出的缺口。
/// 返回的范围同时是帧末高亮带的作用范围,所以这里只求交一次。
fn paint_node(
canvas : Canvas,
node : @core.SemanticsNode,
cell : CellBox,
clip : Clip,
log : DegradeLog,
palette : TuiPalette,
) -> @core.Rect? {
let role = role_tag(node.role)
let kind = role_kind(node.role)
let bounds = if kind == Region { clip.chrome } else { clip.content }
match visible_box(node.frame, bounds) {
None => None
Some(visible) => {
let box = quantize(visible, cell, canvas.cols, canvas.rows)
let seq = canvas.new_seq(role_priority(kind))
if kind == Region {
let (r0, c0, r1, c1) = box
canvas.draw_box(
r0,
c0,
r1,
c1,
palette.attrs_for(node.role, node.state),
seq,
)
} else if kind != Item {
// 条目行自己不写字:它的名字是子节点文本的聚合,
// 再写一遍只会把子节点打断成残字。焦点/选中由帧末高亮带表达。
paint_content(
canvas,
log,
node,
role,
node_text(node),
palette.attrs_for(node.role, node.state),
box,
seq,
)
}
Some(visible)
}
}
}
///|
/// 投影一帧语义负载,返回被丢弃的离屏节点数。
///
/// 两遍:先按广度优先序落字形,再刷焦点/选中的样式带。
/// 顺序不能反,也不能合成一遍 —— 高亮带换的是 attrs 不是字符,
/// 但归属判定只认 seq,把它塞进第一遍会让某个条目为了"显眼"
/// 而吃掉兄弟的文字。
fn project_payload(
canvas : Canvas,
payload : @core.SemanticsFramePayload,
cell : CellBox,
log : DegradeLog,
palette : TuiPalette,
) -> Int {
let nodes = payload.nodes()
let clips = clips_in_tree(
nodes,
payload.root(),
cell,
canvas.cols,
canvas.rows,
)
let painted : Array[(Int, @core.Rect)] = []
let mut offscreen = 0
for i in paint_order(nodes, payload.root()) {
match paint_node(canvas, nodes[i], cell, clips[i], log, palette) {
Some(box) => painted.push((i, box))
None => offscreen = offscreen + 1
}
}
// 样式带再分两小遍:**焦点带先刷、选中带后刷**。带覆盖是整体覆写 attrs,
// 点击选中的条目恰恰同时收进一个带焦点的内嵌按钮(左栏的 ghost 按钮
// 与行同框,右栏的标签按钮盒子从缩进+折叠钮之后才开始):按旧的
// 单遍绘制序,按钮带会吃掉行带 —— 左栏被点行整行无带、旧选中行在焦点
// 让位的那一帧闪现反白;右栏行带只剩按钮盒没盖住的前几格,看起来就是
// 「被点选项前面亮一块」。焦点态浅、选中态是选中的唯一表示(S4 定的),
// 所以选中带必须压轴。焦点与选中同挂在一个节点上时,attrs_for 本来就
// 会把两个状态合成一组 attrs,两遍只是重刷同一组,视觉不变。
for (i, box) in painted {
let state = nodes[i].state
if !state.focused {
continue
}
match quantize_box(box, cell, canvas.cols, canvas.rows) {
None => ()
Some((r0, c0, r1, c1)) =>
canvas.style_range(
r0,
c0,
r1,
c1,
palette.attrs_for(nodes[i].role, state),
)
}
}
for (i, box) in painted {
let state = nodes[i].state
if !state.selected {
continue
}
match quantize_box(box, cell, canvas.cols, canvas.rows) {
None => ()
Some((r0, c0, r1, c1)) =>
canvas.style_range(
r0,
c0,
r1,
c1,
palette.attrs_for(nodes[i].role, state),
)
}
}
offscreen
}