From 6961e1911419c4576b11eb4e414a7bd63dbce237 Mon Sep 17 00:00:00 2001 From: John Alanbrook Date: Thu, 6 Nov 2025 13:49:51 -0600 Subject: [PATCH] clay tree --- prosperon/clay.cm | 162 +++++++++++++++++++++++++++----------- prosperon/clay_input.cm | 170 ++++++++++++++++++++++++++++++---------- 2 files changed, 245 insertions(+), 87 deletions(-) diff --git a/prosperon/clay.cm b/prosperon/clay.cm index 2ab6f461..3badea81 100644 --- a/prosperon/clay.cm +++ b/prosperon/clay.cm @@ -9,6 +9,9 @@ var util = use('util') var input = use('input') var prosperon = use('prosperon') +var CHILDREN = Symbol('children') +var PARENT = Symbol('parent') + function normalizeSpacing(spacing) { if (typeof spacing == 'number') { return {l: spacing, r: spacing, t: spacing, b: spacing} @@ -47,8 +50,11 @@ var clay_base = { var root_item; var root_config; -var boxes = []; +var tree_root; var clay = {} +clay.CHILDREN = CHILDREN +clay.PARENT = PARENT + var focused_textbox = null clay.behave = layout.behave; @@ -58,7 +64,6 @@ clay.draw = function draw(fn) { var size = [prosperon.camera.width, prosperon.camera.height] lay_ctx.reset(); - boxes = []; var root = lay_ctx.item(); // Accept both array and object formats if (Array.isArray(size)) { @@ -68,42 +73,51 @@ clay.draw = function draw(fn) lay_ctx.set_contain(root,layout.contain.row); root_item = root; root_config = Object.assign({}, clay_base); - boxes.push({ + tree_root = { id: root, config: root_config, - parent: null, - }); + }; + tree_root[CHILDREN] = []; + tree_root[PARENT] = null; fn() lay_ctx.run(); - // Adjust bounding boxes for padding - for (var i = 0; i < boxes.length; i++) { - var box = boxes[i]; - box.content = lay_ctx.get_rect(box.id); - box.boundingbox = Object.assign({}, box.content); + // Adjust bounding boxes for padding - traverse tree instead of array + function adjust_bounding_boxes(node) { + node.content = lay_ctx.get_rect(node.id); + node.boundingbox = Object.assign({}, node.content); - var padding = normalizeSpacing(box.config.padding || 0); + var padding = normalizeSpacing(node.config.padding || 0); - box.boundingbox.x -= padding.l; - box.boundingbox.y -= padding.t; - box.boundingbox.width += padding.l + padding.r; - box.boundingbox.height += padding.t + padding.b; + node.boundingbox.x -= padding.l; + node.boundingbox.y -= padding.t; + node.boundingbox.width += padding.l + padding.r; + node.boundingbox.height += padding.t + padding.b; - box.marginbox = Object.assign({}, box.content); - var margin = normalizeSpacing(box.config.margin || 0); - box.marginbox.x -= margin.l; - box.marginbox.y -= margin.t; - box.marginbox.width += margin.l+margin.r; - box.marginbox.height += margin.t+margin.b; - box.content.y *= -1; - box.content.y += size.height; - box.boundingbox.y *= -1; - box.boundingbox.y += size.height; - box.content.anchor_y = 1; - box.boundingbox.anchor_y = 1; + node.marginbox = Object.assign({}, node.content); + var margin = normalizeSpacing(node.config.margin || 0); + node.marginbox.x -= margin.l; + node.marginbox.y -= margin.t; + node.marginbox.width += margin.l+margin.r; + node.marginbox.height += margin.t+margin.b; + node.content.y *= -1; + node.content.y += size.height; + node.boundingbox.y *= -1; + node.boundingbox.y += size.height; + node.content.anchor_y = 1; + node.boundingbox.anchor_y = 1; + + // Recursively adjust children + if (node[CHILDREN]) { + for (var child of node[CHILDREN]) { + adjust_bounding_boxes(child); + } + } } - return boxes; + adjust_bounding_boxes(tree_root); + + return tree_root; } function create_view_fn(base_config) @@ -115,12 +129,21 @@ function create_view_fn(base_config) var prev_item = root_item; var prev_config = root_config; + var prev_tree_root = tree_root; root_item = item; root_config = config; root_config._childIndex = 0; // Initialize child index + // Find the tree node for this item and set it as current tree_root + if (prev_tree_root[CHILDREN] && prev_tree_root[CHILDREN].length > 0) { + tree_root = prev_tree_root[CHILDREN][prev_tree_root[CHILDREN].length - 1]; + } else { + // If no children yet, this shouldn't happen, but handle gracefully + tree_root = prev_tree_root; + } fn?.(); root_item = prev_item; root_config = prev_config; + tree_root = prev_tree_root; } } @@ -183,11 +206,13 @@ function add_item(config) lay_ctx.set_size(item,use_config.size); lay_ctx.set_contain(item,use_config.contain); lay_ctx.set_behave(item,use_config.behave); - boxes.push({ + var tree_node = { id: item, config: use_config, - parent: root_item, - }); + }; + tree_node[CHILDREN] = []; + tree_node[PARENT] = tree_root; + tree_root[CHILDREN].push(tree_node); lay_ctx.insert(root_item,item); // Increment the parent's child index @@ -226,7 +251,6 @@ clay.text = function text(str, ...configs) var tsize = config.font.text_size(str, 0, config.size.x, config.text_break, config.text_align); tsize.x = Math.ceil(tsize.x) tsize.y = Math.ceil(tsize.y) - log.console(json.encode(tsize)) config.size = config.size.map((x,i) => Math.max(x, tsize[i])); config.text = str; add_item(config); @@ -268,15 +292,15 @@ clay.textbox = function(str, on_change, ...configs) { var point = use('point') -clay.draw_commands = function draw_commands(cmds, pos = {x:0,y:0}) +clay.draw_commands = function draw_commands(tree_root, pos = {x:0,y:0}) { - for (var cmd of cmds) { - var config = cmd.config - var boundingbox = geometry.rect_move(cmd.boundingbox,point.add(pos,config.offset)) - var content = geometry.rect_move(cmd.content,point.add(pos, config.offset)) + function draw_node(node) { + var config = node.config + var boundingbox = geometry.rect_move(node.boundingbox,point.add(pos,config.offset)) + var content = geometry.rect_move(node.content,point.add(pos, config.offset)) // Check if this box should use hover styling - if (cmd.state && cmd.state.hovered && config.hovered) { + if (node.state && node.state.hovered && config.hovered) { config.hovered.__proto__ = config config = config.hovered } @@ -294,7 +318,16 @@ clay.draw_commands = function draw_commands(cmds, pos = {x:0,y:0}) if (config.image) draw.image(config.image, content, 0, config.color) + + // Recursively draw children + if (node[CHILDREN]) { + for (var child of node[CHILDREN]) { + draw_node(child); + } + } } + + draw_node(tree_root); } var dbg_colors = {}; @@ -302,15 +335,56 @@ clay.debug_colors = dbg_colors; dbg_colors.content = [1,0,0,0.1]; dbg_colors.boundingbox = [0,1,0,0,0.1]; dbg_colors.margin = [0,0,1,0.1]; -clay.draw_debug = function draw_debug(cmds, pos = {x:0, y:0}) +clay.draw_debug = function draw_debug(tree_root, pos = {x:0, y:0}) { - for (var i = 0; i < cmds.length; i++) { - var cmd = cmds[i]; - var boundingbox = geometry.rect_move(cmd.boundingbox,pos); - var content = geometry.rect_move(cmd.content,pos); + function draw_debug_node(node) { + var boundingbox = geometry.rect_move(node.boundingbox,pos); + var content = geometry.rect_move(node.content,pos); draw.rectangle(content, dbg_colors.content); draw.rectangle(boundingbox, dbg_colors.boundingbox); -// draw.rectangle(geometry.rect_move(cmd.marginbox,pos), dbg_colors.margin); + // draw.rectangle(geometry.rect_move(node.marginbox,pos), dbg_colors.margin); + + // Recursively draw debug for children + if (node[CHILDREN]) { + for (var child of node[CHILDREN]) { + draw_debug_node(child); + } + } + } + + draw_debug_node(tree_root); +} + +clay.print_tree = function print_tree(tree_root, indent = 0) { + log.console(json.encode(tree_root[clay.CHILDREN])) + var indent_str = ' '.repeat(indent) + var node_type = 'unknown' + + if (tree_root.config.text) { + node_type = 'text' + } else if (tree_root.config.image) { + node_type = 'image' + } else if (tree_root.config.contain) { + if (tree_root.config.contain & layout.contain.column) { + node_type = 'vstack' + } else if (tree_root.config.contain & layout.contain.row) { + node_type = 'hstack' + } else { + node_type = 'container' + } + } else { + node_type = 'node' + } + + log.console(`${indent_str}${node_type} (id: ${tree_root.id})`) + + if (tree_root[CHILDREN] && tree_root[CHILDREN].length > 0) { + log.console(`${indent_str} children: ${tree_root[CHILDREN].length}`) + for (var child of tree_root[CHILDREN]) { + print_tree(child, indent + 1) + } + } else { + log.console(`${indent_str} (no children)`) } } diff --git a/prosperon/clay_input.cm b/prosperon/clay_input.cm index ac5e25b3..968114e3 100644 --- a/prosperon/clay_input.cm +++ b/prosperon/clay_input.cm @@ -3,61 +3,145 @@ var geometry = use('geometry') var point = use('point') +var clay = use('prosperon/clay') var clay_input = {} -// Hit-test boxes against a mouse position -// boxes: array of box objects from clay.draw() -// pos: {x, y} position to test -// prev_state: previous input state for tracking changes -clay_input.hit = function hit(boxes, pos, prev_state = {}) { - var hovered = null - var clicked = null - - // Find the topmost hovered box (iterate in reverse for proper z-order) - for (var i = boxes.length - 1; i >= 0; i--) { - var box = boxes[i] - var boundingbox = geometry.rect_move(box.boundingbox, box.config.offset) - if (geometry.rect_point_inside(boundingbox, pos)) { - hovered = box - break +function rect_contains(node, pos) { + var bb = geometry.rect_move(node.boundingbox, node.config.offset || {x: 0, y: 0}) + return geometry.rect_point_inside(bb, pos) +} + +function pointer_enabled(node) { + var p = node.config.pointer_events + if (!p || p == 'auto') return true + if (p == 'none') return false + return true +} + +function should_skip_children(node) { + var p = node.config.pointer_events + if (p == 'box-only') return true + return false +} + +function should_skip_self(node) { + var p = node.config.pointer_events + if (p == 'box-none') return true + return false +} + +clay_input.hit = function hit(tree_root, pos, prev_state = {}) { + function find_path(node, path) { + if (!pointer_enabled(node)) return null + if (!rect_contains(node, pos)) return null + + var next_path = path.concat(node) + + if (node[clay.CHILDREN] && !should_skip_children(node)) { + // Children drawn later should be tested first; reverse if your render order differs + for (var i = node[clay.CHILDREN].length - 1; i >= 0; i--) { + var child = node[clay.CHILDREN][i] + var child_path = find_path(child, next_path) + if (child_path) return child_path + } + } + + if (should_skip_self(node)) return null + return next_path + } + + var path = find_path(tree_root, []) || [] + var deepest = path.length ? path[path.length - 1] : null + + function nearest_actionable(path) { + for (var i = path.length - 1; i >= 0; i--) { + var n = path[i] + if (n && n.config && typeof n.config.action == 'function') return n + } + return null + } + + var action_target = nearest_actionable(path) + + // Hover bookkeeping: let child hover, and optionally parent hover via hover_includes_children + var new_hover_chain = [] + if (deepest) { + if (deepest.config && deepest.config.hovered) { + deepest.state = deepest.state || {} + deepest.state.hovered = true + new_hover_chain.push(deepest) + } + + for (var i = path.length - 2; i >= 0; i--) { + var anc = path[i] + if (anc.config && anc.config.hovered) { + anc.state = anc.state || {} + anc.state.hovered = true + new_hover_chain.push(anc) + } } } - - // Update hover state - if (hovered && hovered.config.hovered) { - hovered.state = hovered.state || {} - hovered.state.hovered = true + + // Clear previous hovers not in the new chain + if (prev_state.hover_chain) { + for (var i = 0; i < prev_state.hover_chain.length; i++) { + var n = prev_state.hover_chain[i] + if (new_hover_chain.indexOf(n) == -1) { + n.state = n.state || {} + n.state.hovered = false + } + } } - - // Clear previous hover state if different - if (prev_state.hovered && prev_state.hovered != hovered) { - prev_state.hovered.state = prev_state.hovered.state || {} - prev_state.hovered.state.hovered = false - } - + return { - hovered: hovered, - clicked: clicked + path: path, + deepest: deepest, + action_target: action_target, + hover_chain: new_hover_chain } } -// Handle click events -clay_input.click = function click(boxes, mousepos, button = 'left') { - var hit_result = clay_input.hit(boxes, mousepos) - var clicked = hit_result.hovered - - if (clicked && clicked.config.action) { - clicked.config.action() - return clicked +clay_input.click = function click(tree_root, mousepos, button = 'left') { + var hit_result = clay_input.hit(tree_root, mousepos, {}) + var target = hit_result.action_target + if (target && target.config.action) target.config.action() + return target || null +} + +clay_input.get_actionable = function get_actionable(tree_root) { + var actionable = [] + function walk(node) { + if (node.config.action) actionable.push(node) + if (node[clay.CHILDREN]) + for (var child of node[clay.CHILDREN]) walk(child) } - - return null + walk(tree_root) + return actionable } -// Get boxes with actions for navigation -clay_input.get_actionable = function get_actionable(boxes) { - return boxes.filter(box => box.config.action) +clay_input.find_by_id = function find_by_id(tree_root, id) { + function rec(node) { + if (node.id == id) return node + if (node[clay.CHILDREN]) + for (var child of node[clay.CHILDREN]) { + var f = rec(child) + if (f) return f + } + return null + } + return rec(tree_root) } -return clay_input \ No newline at end of file +clay_input.filter = function filter(tree_root, predicate) { + var results = [] + function rec(node) { + if (predicate(node)) results.push(node) + if (node[clay.CHILDREN]) + for (var child of node[clay.CHILDREN]) rec(child) + } + rec(tree_root) + return results +} + +return clay_input