// clay.cm - UI layout engine emitting flat drawables with annotated tree // // - Uses meme/merge for config chains // - Emits flat list of drawables for film2d // - Supports scissor clipping // - Returns annotated tree root with .drawables for clay_input compatibility var layout = use('layout') var graphics = use('graphics') var clay = {} // Unique key objects for tree traversal (used by clay_input) var CHILDREN = {} var PARENT = {} clay.CHILDREN = CHILDREN clay.PARENT = PARENT // Layout context var lay_ctx = layout.make_context() // Base configuration for UI elements var base_config = { font: null, background_image: null, slice: 0, font_path: 'fonts/dos', // Default font font_size: 16, color: {r:1, g:1, b:1, a:1}, spacing: 0, padding: 0, margin: 0, offset: {x:0, y:0}, size: null, background_color: null, clipped: false, text_break: 'word', text_align: 'left', max_size: null, contain: 0, behave: 0 } function normalize_color(c, fallback) { var fb = fallback || {r:1, g:1, b:1, a:1} if (!c) return {r:fb.r, g:fb.g, b:fb.b, a:fb.a} return { r: c.r != null ? c.r : fb.r, g: c.g != null ? c.g : fb.g, b: c.b != null ? c.b : fb.b, a: c.a != null ? c.a : fb.a } } function normalize_spacing(s) { if (is_number(s)) return {l:s, r:s, t:s, b:s} if (is_array(s)) { if (length(s) == 2) return {l:s[0], r:s[0], t:s[1], b:s[1]} if (length(s) == 4) return {l:s[0], r:s[1], t:s[2], b:s[3]} } if (is_object(s)) return {l:s.l||0, r:s.r||0, t:s.t||0, b:s.b||0} return {l:0, r:0, t:0, b:0} } // Tree building state var root_item = null var tree_root = null var config_stack = [] // Rewriting state management for cleaner recursion var tree_stack = [] var _next_id = 0 function annotate_tree(node, root_height, parent_node) { var rect = lay_ctx.get_rect(node.id) node.boundingbox = { x: rect.x, y: root_height - (rect.y + rect.height), width: rect.width, height: rect.height } node[CHILDREN] = node.children node[PARENT] = parent_node arrfor(node.children, function(child) { annotate_tree(child, root_height, node) }) } clay.layout = function(fn, size) { lay_ctx.reset() _next_id = 0 var root_id = lay_ctx.item() lay_ctx.set_size(root_id, size) lay_ctx.set_contain(root_id, layout.contain.row) var root_node = { id: root_id, config: meme(base_config, {size: size}), children: [] } tree_stack = [root_node] fn() // User builds tree lay_ctx.run() // Annotate tree for clay_input (boundingbox, CHILDREN, PARENT) annotate_tree(root_node, size.height, null) // Build flat drawable list and attach to tree root root_node.drawables = build_drawables(root_node, size.height) return root_node } function build_drawables(node, root_height, parent, parent_scissor) { var p_abs_x = (parent && parent.x) || 0 var p_abs_y = (parent && parent.y) || 0 var p_layer = (parent && parent.layer) || 0 var rect = lay_ctx.get_rect(node.id) // Calculate absolute world Y for this node (bottom-up layout to top-down render) var abs_y = root_height - (rect.y + rect.height) var abs_x = rect.x // IMPORTANT: The offset in config is applied VISUALLY. var vis_x = abs_x + node.config.offset.x var vis_y = abs_y + node.config.offset.y var drawables = [] // Scissor var current_scissor = parent_scissor var sx = vis_x var sy = vis_y var sw = rect.width var sh = rect.height var clip_right = null var clip_bottom = null if (node.config.clipped) { // Intersect with parent if (parent_scissor) { sx = max(sx, parent_scissor.x) sy = max(sy, parent_scissor.y) clip_right = min(vis_x + sw, parent_scissor.x + parent_scissor.width) clip_bottom = min(vis_y + sh, parent_scissor.y + parent_scissor.height) sw = max(0, clip_right - sx) sh = max(0, clip_bottom - sy) } current_scissor = {x: sx, y: sy, width: sw, height: sh} } // Background if (node.config.background_image) { _next_id = _next_id + 1 if (node.config.slice) { drawables[] = { _id: _next_id, type: 'sprite', image: node.config.background_image, pos: {x: vis_x, y: vis_y}, width: rect.width, height: rect.height, slice: node.config.slice, color: node.config.background_color || {r:1, g:1, b:1, a:1}, layer: p_layer - 0.1, scissor: current_scissor } } else { drawables[] = { _id: _next_id, type: 'sprite', image: node.config.background_image, pos: {x: vis_x, y: vis_y}, width: rect.width, height: rect.height, color: node.config.background_color || {r:1, g:1, b:1, a:1}, layer: p_layer - 0.1, scissor: current_scissor } } } else if (node.config.background_color) { _next_id = _next_id + 1 drawables[] = { _id: _next_id, type: 'rect', pos: {x: vis_x, y: vis_y}, width: rect.width, height: rect.height, color: node.config.background_color, layer: p_layer - 0.1, scissor: current_scissor } } // Content (Image/Text) if (node.config.image) { _next_id = _next_id + 1 drawables[] = { _id: _next_id, type: 'sprite', image: node.config.image, pos: {x: vis_x, y: vis_y}, width: rect.width, height: rect.height, color: node.config.color, layer: p_layer, scissor: current_scissor } } if (node.config.text) { _next_id = _next_id + 1 drawables[] = { _id: _next_id, type: 'text', text: node.config.text, font: node.config.font_path, size: node.config.font_size, color: node.config.color, pos: {x: vis_x, y: vis_y + rect.height}, anchor_y: 1.0, layer: p_layer, scissor: current_scissor } } // Children arrfor(node.children, function(child) { drawables = array(drawables, build_drawables(child, root_height, {x: vis_x, y: vis_y, layer: p_layer + 0.01}, current_scissor)) }) return drawables } // --- Item Creation Helpers --- function process_configs(configs) { var cfg = meme(base_config, configs) // Parse shorthand font string (e.g. 'blackcastle.64') into font_path and font_size var font_parts = null var parsed_size = null if (cfg.font && is_text(cfg.font)) { font_parts = array(cfg.font, '.') if (length(font_parts) >= 2) { parsed_size = number(font_parts[length(font_parts) - 1]) if (parsed_size) { cfg.font_size = parsed_size cfg.font_path = font_parts[0] } } } cfg.color = normalize_color(cfg.color, base_config.color) if (cfg.background_color) cfg.background_color = normalize_color(cfg.background_color, {r:1,g:1,b:1,a:1}) if (!cfg.offset) cfg.offset = {x:0, y:0} else cfg.offset = {x: cfg.offset.x || 0, y: cfg.offset.y || 0} return cfg } function push_node(configs, contain_mode) { var config = process_configs(configs) if (contain_mode != null) config.contain = contain_mode var item = lay_ctx.item() // Apply layout props lay_ctx.set_margins(item, normalize_spacing(config.margin)) lay_ctx.set_contain(item, config.contain) lay_ctx.set_behave(item, config.behave) var s = config.size if (s) { if (is_array(s)) s = {width: s[0], height: s[1]} lay_ctx.set_size(item, s) } var node = { id: item, config: config, children: [] } // Add to parent var parent = tree_stack[length(tree_stack)-1] parent.children[] = node lay_ctx.insert(parent.id, item) tree_stack[] = node return node } function pop_node() { tree_stack[] } // Generic container clay.container = function(configs, fn) { var _fn = is_function(configs) ? configs : fn var _configs = is_function(configs) ? [{}] : (is_array(configs) ? configs : [configs]) push_node(_configs, null) if (_fn) _fn() pop_node() } // Stacks clay.vstack = function(configs, fn) { var _fn = is_function(configs) ? configs : fn var _configs = is_function(configs) ? [{}] : (is_array(configs) ? configs : [configs]) var c = layout.contain.column push_node(_configs, c) if (_fn) _fn() pop_node() } clay.hstack = function(configs, fn) { var _fn = is_function(configs) ? configs : fn var _configs = is_function(configs) ? [{}] : (is_array(configs) ? configs : [configs]) var c = layout.contain.row push_node(_configs, c) if (_fn) _fn() pop_node() } clay.zstack = function(configs, fn) { var _fn = is_function(configs) ? configs : fn var _configs = is_function(configs) ? [{}] : (is_array(configs) ? configs : [configs]) var c = layout.contain.layout push_node(_configs, c) if (_fn) _fn() pop_node() } // Leaf nodes clay.image = function(path, configs, extra) { var img = graphics.texture(path) var c = [{image: path}] var _configs = configs ? (is_array(configs) ? configs : [configs]) : [] if (extra) _configs = array(_configs, is_array(extra) ? extra : [extra]) var final_config = process_configs(_configs) if (!final_config.size && !final_config.behave) c[] = {size: {width: img.width, height: img.height}} push_node(array(c, _configs), null) pop_node() } clay.text = function(str, configs, extra) { var c = [{text: str}] var _configs = configs ? (is_array(configs) ? configs : [configs]) : [] if (extra) _configs = array(_configs, is_array(extra) ? extra : [extra]) var final_config = process_configs(_configs) if (!final_config.size && !final_config.behave) { c[] = {size: {width: 100, height: 20}} } push_node(array(c, _configs), null) pop_node() } clay.rectangle = function(configs) { var _configs = configs ? (is_array(configs) ? configs : [configs]) : [{}] push_node(_configs, null) pop_node() } clay.button = function(str, action, configs) { var btn_config = [{ padding: 10, background_color: {r:0.3, g:0.3, b:0.4, a:1} }] var _configs = is_array(configs) ? configs : [configs] clay.zstack(array(btn_config, _configs), function() { clay.text(str, {color: {r:1,g:1,b:1,a:1}}) }) } // Spacer — fills available space clay.spacer = function(config) { var cfg = config || {} if (!cfg.behave) cfg.behave = layout.behave.hfill | layout.behave.vfill push_node([cfg], null) pop_node() } // Convenience draw wrapper — auto-detects call patterns: // clay.draw(fn) — default 640x360 // clay.draw(fn, size_obj) — fn first, size object second // clay.draw(size_array, fn) — size array first, fn second clay.draw = function(arg1, arg2) { var fn = null var size = {width: 640, height: 360} if (is_function(arg1)) { fn = arg1 if (arg2) { if (is_array(arg2)) size = {width: arg2[0], height: arg2[1]} else if (is_object(arg2)) size = arg2 } } else if (is_array(arg1)) { size = {width: arg1[0], height: arg1[1]} fn = arg2 } return clay.layout(fn, size) } // Offset all drawables in an array by a position clay.offset_drawables = function(drawables, offset) { var result = [] var i = 0 var d = null for (i = 0; i < length(drawables); i = i + 1) { d = meme(drawables[i]) d.pos = {x: d.pos.x + offset.x, y: d.pos.y + offset.y} result[] = d } return result } // Constants clay.behave = layout.behave clay.contain = layout.contain return clay