use "collections"
trait tag _HitTestRequester
be hit_test_result(widget: (Any tag | None))
actor Compositor is WidgetParent
"""
Receives grids from widget-actors, composites by z-order,
diffs against previous frame, and writes ANSI output.
"""
let _output: TerminalOutput tag
var _screen_width: USize
var _screen_height: USize
var _prev_frame: Grid
// Registered widgets: identity, viewport, latest grid.
// Order in array is registration order; z_order is in ViewPort.
var _widgets: Array[(Any tag, ViewPort, Grid)]
var _dirty: Bool = false
new create(output: TerminalOutput tag, width: USize, height: USize) =>
_output = output
_screen_width = width
_screen_height = height
_prev_frame = Grid.filled(width, height, Cell.empty())
_widgets = Array[(Any tag, ViewPort, Grid)]
// Send initial clear screen + hide cursor
let init = recover iso
let buf = Array[U8]
for b in AnsiEncoder.clear_screen().values() do buf.push(b) end
for b in AnsiEncoder.hide_cursor().values() do buf.push(b) end
buf
end
_output.write(consume init)
be register_root(widget: Widget tag) =>
"""
Register the root widget at NorthWest filling the full screen.
Does not send a resize — the caller must send resize() to the
widget directly to ensure correct message ordering.
"""
let viewport = ViewPort(NorthWest, _screen_width, _screen_height)
_widgets.push((widget, viewport,
Grid.filled(_screen_width, _screen_height, Cell.empty())))
be receive_grid(widget: Any tag, grid: Grid) =>
"""
Receive an updated grid from a widget. Stores the grid and schedules
a deferred recompose if not already pending.
"""
try
let idx = _find_widget(widget)?
(let w, let vp, _) = _widgets(idx)?
_widgets(idx)? = (w, vp, grid)
if not _dirty then
_dirty = true
_deferred_compose()
end
end
be _deferred_compose() =>
"""
Runs after all queued receive_grid messages have been processed.
Performs one compose-and-render pass.
"""
_dirty = false
_compose_and_render()
be hit_test(col: USize, row: USize, requester: _HitTestRequester tag) =>
"""
Find the topmost widget at the given screen coordinate and send the
result back to the requester.
"""
// Walk widgets in reverse z-order (highest first) to find topmost
let sorted = _sorted_indices()
var i = sorted.size()
while i > 0 do
i = i - 1
try
let idx = sorted(i)?
(let w, let vp, let g) = _widgets(idx)?
(let ax, let ay) = _resolve_position(vp, g.width, g.height)
if (col >= ax) and (col < (ax + g.width))
and (row >= ay) and (row < (ay + g.height))
then
requester.hit_test_result(w)
return
end
end
end
requester.hit_test_result(None)
be screen_resize(width: USize, height: USize) =>
"""
Handle terminal resize: update dimensions, clear screen, and recompose.
"""
_screen_width = width
_screen_height = height
_prev_frame = Grid.filled(width, height, Cell.empty())
let clear = recover iso
let buf = Array[U8]
for b in AnsiEncoder.clear_screen().values() do buf.push(b) end
buf
end
_output.write(consume clear)
_compose_and_render()
fun ref _compose_and_render() =>
let sw = _screen_width
let sh = _screen_height
// 1. Pre-extract resolved positions and grids in z-order.
let sorted = _sorted_indices()
let layers_iso: Array[(USize, USize, Grid)] iso =
recover iso Array[(USize, USize, Grid)] end
for sort_i in Range(0, sorted.size()) do
try
let idx = sorted(sort_i)?
(_, let vp, let g) = _widgets(idx)?
(let ax, let ay) = _resolve_position(vp, g.width, g.height)
layers_iso.push((ax, ay, g))
end
end
let layers: Array[(USize, USize, Grid)] val = consume layers_iso
// 2. Build frame buffer. All inputs to this block are val or sendable.
let cells_val: Array[Cell] val = recover val
let size = sw * sh
let cells = Array[Cell](size)
let empty = Cell.empty()
for i in Range(0, size) do
cells.push(empty)
end
for layer in layers.values() do
(let ax, let ay, let g) = layer
for grow in Range(0, g.height) do
let screen_row = ay + grow
if screen_row >= sh then break end
for gcol in Range(0, g.width) do
let screen_col = ax + gcol
if screen_col >= sw then break end
match g(gcol, grow)
| let cell: Cell =>
// Wide char clipping: if a wide char would extend past right edge,
// replace with a space
if (cell.width == 2) and ((screen_col + 1) >= sw) then
try cells((screen_row * sw) + screen_col)? = Cell.empty() end
else
try cells((screen_row * sw) + screen_col)? = cell end
end
end
end
end
end
cells
end
let curr_frame = GridFactory(sw, sh, cells_val)
// 3. Diff current vs previous
let changes = Differ.diff(_prev_frame, curr_frame)
// 4. Render changes as ANSI output
if changes.size() > 0 then
let output = recover iso
let buf = Array[U8]
for change in changes.values() do
(let col, let row, let cell) = change
for b in AnsiEncoder.move_to(col, row).values() do buf.push(b) end
for b in AnsiEncoder.reset().values() do buf.push(b) end
if cell.attrs != 0 then
for b in AnsiEncoder.set_attrs(cell.attrs).values() do buf.push(b) end
end
for b in AnsiEncoder.set_fg(cell.fg).values() do buf.push(b) end
for b in AnsiEncoder.set_bg(cell.bg).values() do buf.push(b) end
for b in AnsiEncoder.write_char(cell.char).values() do buf.push(b) end
end
buf
end
_output.write(consume output)
end
// 5. Current frame becomes previous
_prev_frame = curr_frame
fun _find_widget(widget: Any tag): USize ? =>
"""Find widget index by identity. Raises error if not found."""
for i in Range(0, _widgets.size()) do
try
(let w, _, _) = _widgets(i)?
if w is widget then return i end
end
end
error
fun _sorted_indices(): Array[USize] =>
"""Return widget indices sorted by z_order ascending (insertion sort)."""
let indices = Array[USize](_widgets.size())
for idx in Range(0, _widgets.size()) do
indices.push(idx)
end
// Insertion sort by z_order
var i: USize = 1
while i < indices.size() do
try
let key_idx = indices(i)?
(_, let key_vp, _) = _widgets(key_idx)?
let key_z = key_vp.z_order
var j = i
while j > 0 do
let prev_idx = indices(j - 1)?
(_, let prev_vp, _) = _widgets(prev_idx)?
if prev_vp.z_order <= key_z then break end
indices(j)? = prev_idx
j = j - 1
end
indices(j)? = key_idx
end
i = i + 1
end
indices
fun _resolve_position(vp: ViewPort, widget_width: USize, widget_height: USize): (USize, USize) =>
"""Map anchor + offset to absolute screen coordinates."""
(let base_x: ISize, let base_y: ISize) = match vp.anchor
| North =>
(_center_h(widget_width), ISize(0))
| NorthEast =>
(_right_edge(widget_width), ISize(0))
| East =>
(_right_edge(widget_width), _center_v(widget_height))
| SouthEast =>
(_right_edge(widget_width), _bottom_edge(widget_height))
| South =>
(_center_h(widget_width), _bottom_edge(widget_height))
| SouthWest =>
(ISize(0), _bottom_edge(widget_height))
| West =>
(ISize(0), _center_v(widget_height))
| NorthWest =>
(ISize(0), ISize(0))
| Center =>
(_center_h(widget_width), _center_v(widget_height))
end
let rx = base_x + vp.offset_x
let ry = base_y + vp.offset_y
let x: USize = if rx < 0 then USize(0) else rx.usize() end
let y: USize = if ry < 0 then USize(0) else ry.usize() end
(x, y)
fun _center_h(widget_width: USize): ISize =>
if _screen_width >= widget_width then
ISize.from[USize]((_screen_width - widget_width) / 2)
else
ISize(0)
end
fun _center_v(widget_height: USize): ISize =>
if _screen_height >= widget_height then
ISize.from[USize]((_screen_height - widget_height) / 2)
else
ISize(0)
end
fun _right_edge(widget_width: USize): ISize =>
if _screen_width >= widget_width then
ISize.from[USize](_screen_width - widget_width)
else
ISize(0)
end
fun _bottom_edge(widget_height: USize): ISize =>
if _screen_height >= widget_height then
ISize.from[USize](_screen_height - widget_height)
else
ISize(0)
end