From a8a51718fcf36a9b33df2e8da34e40bfe37e787e Mon Sep 17 00:00:00 2001 From: Dustin Swan Date: Sun, 15 Feb 2026 23:37:43 -0700 Subject: [PATCH] keyboard nav in tree view. --- src/cg/06-tree.cg | 90 +++++++++++++++++++++++++++++------------ src/runtime-compiled.ts | 9 ++++- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/cg/06-tree.cg b/src/cg/06-tree.cg index 74f63c2..dcc9044 100644 --- a/src/cg/06-tree.cg +++ b/src/cg/06-tree.cg @@ -1,13 +1,13 @@ -treeNodeHeight = value path expanded \ - lineH = 20; - - valueLabel = v \ v +valueLabel = v \ v | NumberValue n \ Some n | StringValue n \ Some n | ConstructorValue { tag = tag } \ Some tag | FunctionValue _ \ Some "" | _ \ None; +treeNodeHeight = value path expanded \ + lineH = 20; + value | RecordValue entries \ ( (contains path expanded) @@ -27,13 +27,35 @@ treeNodeHeight = value path expanded \ | False \ lineH) | _ \ lineH; +visiblePaths = value path expanded \ value + | RecordValue entries \ ( + [path, ...(contains path expanded) + | True \ flatten (map (entry \ + entryPath = path & "." & entry.key; + (valueLabel entry.value) + | Some _ \ [entryPath] + | None \ visiblePaths entry.value entryPath expanded + ) entries) + | False \ []]) + | ListValue items \ ( + [path, ...(contains path expanded) + | True \ flatten (mapWithIndex (item i \ + itemPath = path & "." & (show i); + (valueLabel item) + | Some _ \ [itemPath] + | None \ visiblePaths item itemPath expanded + ) items) + | False \ []]) + | _ \ [path]; + treeNode = config \ depth = config.depth; indent = depth * 20; - simple = content color \ + simple = path content color \ + selected = (config.selectedPath | Some p \ p == path | None \ False); ui.positioned { x = indent, y = 0, - child = ui.text { content = content, color = color } + child = ui.text { content = content, color = (selected | True \ "white" | False \ color) } }; valueLabel = value \ value @@ -46,16 +68,16 @@ treeNode = config \ header = isExp label color \ ui.clickable { onClick = _ \ config.onToggle config.path, - child = simple ((isExp | True \ "▼ " | False \ "▶ ") & label) color + child = simple config.path (config.prefix & (isExp | True \ "▼ " | False \ "▶ ") & label) color }; isExp = contains config.path config.expanded; config.value - | NumberValue n \ simple (show n) "#6cf" - | StringValue n \ simple ("\"" & n & "\"") "#f6a" - | ConstructorValue { tag = tag } \ simple tag "#fc6" - | FunctionValue _ \ simple "" "#888" + | NumberValue n \ simple config.path (config.prefix & (show n)) "#6cf" + | StringValue n \ simple config.path (config.prefix & "\"" & n & "\"") "#f6a" + | ConstructorValue { tag = tag } \ simple config.path (config.prefix & tag) "#fc6" + | FunctionValue _ \ simple config.path (config.prefix & "") "#888" | RecordValue entries \ ui.column { gap = 0, children = [ header isExp ((show (len entries)) & " fields") "#888", @@ -63,12 +85,8 @@ treeNode = config \ | True \ [ui.clickable { onClick = _ \ noOp, child = ui.column { gap = 0, children = map (entry \ - (valueLabel entry.value) - | Some label \ simple (entry.key & " = " & label) "#aaa" - | None \ ui.column { gap = 0, children = [ - simple entry.key "#aaa", - treeNode { value = entry.value, depth = depth + 1, path = config.path & "." & entry.key, expanded = config.expanded, onToggle = config.onToggle } - ]} + pfx = (valueLabel entry.value) | Some _ \ " = " | None \ " "; + treeNode { value = entry.value, depth = depth + 1, path = config.path & "." & entry.key, expanded = config.expanded, onToggle = config.onToggle, selectedPath = config.selectedPath, prefix = entry.key & pfx } ) entries } }] | False \ []) @@ -80,12 +98,7 @@ treeNode = config \ | True \ [ui.clickable { onClick = _ \ noOp, child = ui.column { gap = 0, children = mapWithIndex (item i \ - (valueLabel item) - | Some label \ simple ((show i) & " : " & label) "#aaa" - | None \ ui.column { gap = 0, children = [ - simple (show i) "#aaa", - treeNode { value = item, depth = depth + 1, path = config.path & "." & (show i), expanded = config.expanded, onToggle = config.onToggle } - ]} + treeNode { value = item, depth = depth + 1, path = config.path & "." & (show i), expanded = config.expanded, onToggle = config.onToggle, selectedPath = config.selectedPath, prefix = (show i) & ": " } ) items } }] | False \ []) @@ -94,8 +107,11 @@ treeNode = config \ tree = config \ ui.stateful { + focusable = True, + autoFocus = True, + key = "tree-" & config.path, - init = { expanded = [], scrollY = 0 }, + init = { expanded = [], scrollY = 0, selectedIndex = 0 }, update = state event \ event | Toggle path \ ((contains path state.expanded) @@ -107,11 +123,35 @@ tree = config \ newY = max 0 (min (totalH - config.h) (state.scrollY + delta.deltaY)); { state = state.{ scrollY = newY }, emit = [] }) + | Key { key = "ArrowDown" } \ ( + paths = visiblePaths config.value config.path state.expanded; + newIndex = min (len paths - 1) (state.selectedIndex + 1); + _ = debug "newIndex" newIndex; + { state = state.{ selectedIndex = newIndex }, emit = [] }) + + | Key { key = "ArrowUp" } \ ( + # paths = visiblePaths config.value config.path state.expanded; + newIndex = max 0 (state.selectedIndex - 1); + _ = debug "newIndex" newIndex; + { state = state.{ selectedIndex = newIndex }, emit = [] }) + + | Key { key = "Enter" } \ ( + paths = visiblePaths config.value config.path state.expanded; + selected = nth state.selectedIndex paths; + selected + | Some path \ ((contains path state.expanded) + | True \ { state = state.{ expanded = filter (p \ p != path) state.expanded }, emit = [] } + | False \ { state = state.{ expanded = [path, ...state.expanded] }, emit = [] } + ) + | None \ { state = state, emit = [] }) + | _ \ { state = state, emit = [] }, view = state emit \ onToggle = path \ emit (Toggle path); totalH = treeNodeHeight config.value config.path state.expanded; + paths = visiblePaths config.value config.path state.expanded; + selectedPath = nth state.selectedIndex paths; scrollable { w = config.w, @@ -121,6 +161,6 @@ tree = config \ scrollX = 0, scrollY = state.scrollY, onScroll = delta \ emit (Scrolled delta), - child = treeNode { value = config.value, path = config.path, depth = 0, expanded = state.expanded, onToggle = onToggle } + child = treeNode { value = config.value, path = config.path, depth = 0, expanded = state.expanded, onToggle = onToggle, selectedPath = selectedPath, prefix = "" } } }; diff --git a/src/runtime-compiled.ts b/src/runtime-compiled.ts index af88b21..dea2c15 100644 --- a/src/runtime-compiled.ts +++ b/src/runtime-compiled.ts @@ -6,6 +6,7 @@ type ComponentInstance = { state: any; update: (state: any) => (event: any) => any; view: (state: any) => any; + focusable: boolean; }; export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { @@ -79,13 +80,15 @@ export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { instance = { state: ui.init, update: ui.update, - view: ui.view + view: ui.view, + focusable: ui.focusable?._tag === 'True' }; componentInstances.set(fullKey, instance); } else { // refresh closures, pick up new values instance.update = ui.update; instance.view = ui.view; + instance.focusable = ui.focusable?._tag === 'True' } if (ui.autoFocus?._tag === 'True' && isNew) { @@ -209,6 +212,10 @@ export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { } if (event._tag === 'ComponentEvent') { + const instance = componentInstances.get(event._0); + if (instance?.focusable) { + setFocus(event._0); + } handleComponentEvent(event._0, event._1); return; }