///|
/// MoonBit 终端画布(playground 宿主层,纯 MoonBit,wasm-gc/native/js 均可编译)。
///
/// 组成:
/// - Camera:视口(平移/缩放)与屏幕字符坐标 ↔ 世界坐标变换
/// - ScreenBuf:字符栅格(每格 = 字符 + ANSI 前缀)
/// - draw_document:Document → 栅格(树序:容器先画、子节点覆盖)
/// - pick_at:屏幕 → 世界 → 最上层节点(供拖拽命令)
///
/// 拖拽坐标先经相机逆变换,再交给引擎会话协议(handle_human)。
pub(all) struct Camera {
mut pan_x : Double
mut pan_y : Double
mut zoom : Double // 1 屏幕列 = zoom 个世界像素;行高为列宽的 2 倍
}
///|
pub fn Camera::new() -> Camera {
{ pan_x: 0.0, pan_y: 0.0, zoom: 1.0, }
}
///|
pub fn camera_zoom_in(c : Camera) -> Unit {
if c.zoom < 4.0 {
c.zoom = c.zoom * 2.0
}
}
///|
pub fn camera_zoom_out(c : Camera) -> Unit {
if c.zoom > 0.125 {
c.zoom = c.zoom / 2.0
}
}
///|
pub fn camera_pan(c : Camera, dx : Double, dy : Double) -> Unit {
c.pan_x = c.pan_x + dx
c.pan_y = c.pan_y + dy
}
///|
struct CamView {
sx : Double
sy : Double
}
///|
fn to_screen_f(c : Camera, wx : Double, wy : Double) -> CamView {
{ sx: (wx - c.pan_x) * c.zoom, sy: (wy - c.pan_y) * c.zoom / 2.0, }
}
///|
fn to_world_f(c : Camera, col : Double, row : Double) -> (Double, Double) {
(col / c.zoom + c.pan_x, row * 2.0 / c.zoom + c.pan_y)
}
// ---------------------------------------------------------------------------
// 字符栅格
// ---------------------------------------------------------------------------
///|
struct Cell {
ch : Char
color : String // ANSI 前缀;"" = 默认前景
}
///|
struct ScreenBuf {
cols : Int
rows : Int
cells : Array[Cell]
}
///|
fn screen_new(cols : Int, rows : Int) -> ScreenBuf {
let cells : Array[Cell] = Array::new()
let total = cols * rows
let mut i = 0
let total = cols * rows
while i < total {
cells.push({ ch: ' ', color: "", })
i = i + 1
}
{ cols, rows, cells, }
}
///|
fn screen_set(
s : ScreenBuf,
col : Int,
row : Int,
ch : Char,
color : String,
) -> Unit {
if col >= 0 && col < s.cols && row >= 0 && row < s.rows {
s.cells[row * s.cols + col] = { ch, color, }
}
}
///|
fn screen_char_at(s : ScreenBuf, col : Int, row : Int) -> Char? {
if col >= 0 && col < s.cols && row >= 0 && row < s.rows {
Some(s.cells[row * s.cols + col].ch)
} else {
None
}
}
///|
fn screen_render(s : ScreenBuf) -> String {
let sb = StringBuilder::new()
for row in 0.. String {
"\u{1b}[2J\u{1b}[H" + screen_render(s)
}
// ---------------------------------------------------------------------------
// 颜色
// ---------------------------------------------------------------------------
///|
const ANSI_BORDER : String = "\u{1b}[90m"
///|
const ANSI_ALERT : String = "\u{1b}[91m"
///|
const ANSI_LABEL_DARK : String = "\u{1b}[37m"
///|
const ANSI_LABEL_LIGHT : String = "\u{1b}[97m"
///|
fn rgb_of(hex : String) -> (Int, Int, Int)? {
let body = if has_pref(hex, "#") { hex.substring(start=1) } else { hex }
if body.length() != 6 {
return None
}
let r = match hex_digit_c(body, 0) {
Some(a) =>
match hex_digit_c(body, 1) {
Some(b) => a * 16 + b
None => return None
}
None => return None
}
let g = match hex_digit_c(body, 2) {
Some(a) =>
match hex_digit_c(body, 3) {
Some(b) => a * 16 + b
None => return None
}
None => return None
}
let b = match hex_digit_c(body, 4) {
Some(a) =>
match hex_digit_c(body, 5) {
Some(b2) => a * 16 + b2
None => return None
}
None => return None
}
Some((r, g, b))
}
///|
/// fill 色暗化到约 27% 亮度(保留色相),返回 ANSI 前缀;无 rgb 则空串
fn faint_prefix(hex : String) -> String {
match rgb_of(hex) {
None => ""
Some((r, g, b)) => "\u{1b}[38;2;\{r / 4};\{g / 4};\{b / 4}m"
}
}
///|
fn pick_label(n : @core.Node) -> String {
if n.text != "" {
n.text
} else {
n.name
}
}
///|
fn label_color_of(n : @core.Node) -> String {
let f = n.style.fill
let dark = f == "#FFFFFF" ||
f == "none" ||
f == "#EEF2FF" ||
f == "#E0E7FF" ||
f == "#CCFBF1" ||
f == "#F2F3F5"
if dark {
ANSI_LABEL_DARK
} else {
ANSI_LABEL_LIGHT
}
}
// ---------------------------------------------------------------------------
// 绘制
// ---------------------------------------------------------------------------
///|
/// 画一个世界矩形:box-drawing 边框 + 淡填色 + 标签(第 2 行起)
fn draw_world_rect(
buf : ScreenBuf,
camera : Camera,
x : Double,
y : Double,
w : Double,
h : Double,
border : String,
fill_hex : String,
label : String,
label_color : String,
) -> Unit {
let t0 = to_screen_f(camera, x, y)
let t1 = to_screen_f(camera, x + w, y + h)
let col0 = t0.sx.to_int()
let row0 = t0.sy.to_int()
let col1 = t1.sx.to_int()
let row1 = t1.sy.to_int()
let sw = col1 - col0
let sh = row1 - row0
if sw <= 1 || sh <= 1 {
screen_set(buf, col0, row0, '·', border)
return
}
let fill : String = if has_pref(fill_hex, "#") {
faint_prefix(fill_hex)
} else {
""
}
// 内部填充(不覆盖边框线所在的四边)
for row in (row0 + 1)..<(row1 - 1) {
for col in (col0 + 1)..<(col1 - 1) {
screen_set(buf, col, row, '·', fill)
}
}
for col in col0..
if at + i < col1 - 1 {
match screen_char_at(buf, at + i, label_row) {
Some(' ') => screen_set(buf, at + i, label_row, c, label_color)
Some('·') => screen_set(buf, at + i, label_row, c, label_color)
_ => ()
}
}
None => ()
}
}
}
///|
/// 文档 → 栅格。高亮节点用亮色边框(拖拽/告警聚焦)。
pub fn draw_document(
buf : ScreenBuf,
camera : Camera,
doc : @core.Document,
highlight : String?,
) -> Unit {
let layout = @core.solve(doc)
draw_tree(buf, camera, doc, doc.root_id, layout, highlight)
}
///|
fn draw_tree(
buf : ScreenBuf,
camera : Camera,
doc : @core.Document,
id : String,
layout : @core.SolvedLayout,
highlight : String?,
) -> Unit {
match doc.nodes.get(id) {
None => ()
Some(n) =>
match layout.rect_of(id) {
None => ()
Some(r) => {
let border : String = match highlight {
Some(hid) => if hid == n.id { ANSI_ALERT } else { ANSI_BORDER }
None => ANSI_BORDER
}
let fill_hex : String = if n.style.fill == "none" {
"#FFFFFF"
} else {
n.style.fill
}
draw_world_rect(
buf,
camera,
r.x,
r.y,
r.width,
r.height,
border,
fill_hex,
pick_label(n),
label_color_of(n),
)
for cid in n.children.iter() {
draw_tree(buf, camera, doc, cid, layout, highlight)
}
}
}
}
}
///|
/// 命中检测:屏幕列/行 → 世界坐标 → 含该点的最小面积节点 id
pub fn pick_at(
doc : @core.Document,
camera : Camera,
col : Double,
row : Double,
) -> String? {
let (wx, wy) = to_world_f(camera, col, row)
let layout = @core.solve(doc)
let mut best : String? = None
let mut best_area : Double = 0.0
for id in doc.nodes.keys() {
match layout.rect_of(id) {
Some(r) =>
if wx >= r.x && wy >= r.y && wx < r.x + r.width && wy < r.y + r.height {
let area = r.width * r.height
match best {
None => {
best = Some(id)
best_area = area
}
Some(_) =>
if area < best_area {
best = Some(id)
best_area = area
}
}
}
None => ()
}
}
best
}