diff --git a/src/cg/03-ui-components.cg b/src/cg/03-ui-components.cg index 0b49e32..ec8ccfe 100644 --- a/src/cg/03-ui-components.cg +++ b/src/cg/03-ui-components.cg @@ -12,21 +12,10 @@ center = parentW parentH child \ childSize = ui.measure child; ui.positioned { x = (parentW - childSize.width) / 2, y = (parentH - childSize.height) / 2, child = child }; -scrollable = config \ - ui.clip { - w = config.w, - h = config.h, - child = ui.positioned { - x = 0 - config.scrollX, - y = 0 - config.scrollY, - child = config.child - } - }; - # button : Record -> ui button = config \ ui.clickable { - event = config.event, + onClick = config.event, child = ui.stack { children = [ ui.rect { w = 100, h = 40, color = "#eee" }, diff --git a/src/cg/05-palette.cg b/src/cg/05-palette.cg index 8d4efe8..e43c008 100644 --- a/src/cg/05-palette.cg +++ b/src/cg/05-palette.cg @@ -50,7 +50,7 @@ palette = config \ color = (config.selected | True \ "rgba(255,255,255,0.2)" | False \ "transparent"); ui.clickable { - event = config.onClick, + onClick = config.onClick, child = ui.stack { children = [ ui.rect { w = config.w, h = config.h, color = color }, @@ -109,11 +109,12 @@ palette = config \ onChange = text \ batch [paletteState.query := text], }, - scrollable { + ui.scrollable { w = contentWidth, h = listHeight, scrollX = 0, scrollY = paletteState.scrollOffset, + onScroll = _ \ noOp, child = ui.column { gap = 1, children = [ @@ -131,7 +132,7 @@ palette = config \ w = contentWidth, h = textInputHeight, selected = (effectiveIndex == i), - onClick = config.onSelect data.label + onClick = _ \ config.onSelect data.label } | _ \ empty ) results) diff --git a/src/cg/10-os.cg b/src/cg/10-os.cg index 1940848..986d0f3 100644 --- a/src/cg/10-os.cg +++ b/src/cg/10-os.cg @@ -100,7 +100,7 @@ renderWindow = window isActive \ children = [ # close button ui.clickable { - event = closeWindowById window.id, + onClick = _ \ closeWindowById window.id, child = ui.stack { children = [ # button background diff --git a/src/runtime-compiled.ts b/src/runtime-compiled.ts index cebf60c..4081f13 100644 --- a/src/runtime-compiled.ts +++ b/src/runtime-compiled.ts @@ -1,4 +1,4 @@ -import { render, hitTest } from './ui'; +import { render, hitTest, scrollHitTest } from './ui'; type UIValue = any; @@ -99,7 +99,7 @@ export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { viewUI = { kind: 'clickable', child: viewUI, - event: { _tag: 'FocusAndClick', _0: fullKey } + onClick: (coords: any) => ({ _tag: 'FocusAndClick', _0: fullKey, _1: coords }) }; } return expandStateful(viewUI, [...path, ui.key], renderedKeys); @@ -117,6 +117,7 @@ export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { } case 'clickable': + case 'scrollable': case 'padding': case 'positioned': case 'opacity': @@ -204,6 +205,21 @@ export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { return; } + function dispatchToFocused(event: any) { + if (focusedComponentKey) { + // send to focused component + handleComponentEvent(focusedComponentKey, event); + + // bubble up to ancestors + for (const key of componentInstances.keys()) { + if (key !== focusedComponentKey && focusedComponentKey.startsWith(key + '.')) { + handleComponentEvent(key, event); + } + } + } + + } + canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; @@ -211,24 +227,15 @@ export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { const hitResult = hitTest(x, y); if (hitResult) { - const { event, relativeX, relativeY } = hitResult; - - if (event._tag === 'FocusAndClick') { - handleEvent({ - _tag: 'FocusAndClick', - _0: event._0, - _1: { x: Math.floor(relativeX), y: Math.floor(relativeY) } - }); - } else { - handleEvent(event); - } + const coords = { x: Math.floor(hitResult.relativeX), y: Math.floor(hitResult.relativeY) }; + handleEvent(hitResult.onClick(coords)); } rerender(); }); window.addEventListener('keydown', (e) => { - const event = { + dispatchToFocused({ _tag: 'Key', _0: { key: e.key, @@ -238,20 +245,33 @@ export function runAppCompiled(canvas: HTMLCanvasElement, store: any) { shift: { _tag: e.shiftKey ? 'True' : 'False' }, printable: { _tag: (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) ? 'True' : 'False' } } - }; + }); - if (focusedComponentKey) { - // send to focused component - handleComponentEvent(focusedComponentKey, event); + e.preventDefault(); + rerender(); + }); - // bubble up to ancestors - for (const key of componentInstances.keys()) { - if (key !== focusedComponentKey && focusedComponentKey.startsWith(key + '.')) { - handleComponentEvent(key, event); - } - } + canvas.addEventListener('wheel', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const hit = scrollHitTest(x, y); + if (hit) { + const delta = { deltaX: Math.round(e.deltaX), deltaY: Math.round(e.deltaY) }; + handleEvent(hit.onScroll(delta)); } + /* + dispatchToFocused({ + _tag: 'Scroll', + _0: { + deltaX: Math.round(e.deltaX), + deltaY: Math.round(e.deltaY) + } + }); + */ + e.preventDefault(); rerender(); }); diff --git a/src/runtime-js.ts b/src/runtime-js.ts index 45077d7..17a968a 100644 --- a/src/runtime-js.ts +++ b/src/runtime-js.ts @@ -36,6 +36,7 @@ export const _rt = { padding: (config: any) => ({ kind: 'padding', ...config }), positioned: (config: any) => ({ kind: 'positioned', ...config }), clickable: (config: any) => ({ kind: 'clickable', ...config }), + scrollable: (config: any) => ({ kind: 'scrollable', ...config }), clip: (config: any) => ({ kind: 'clip', ...config }), opacity: (config: any) => ({ kind: 'opacity', ...config }), stateful: (config: any) => ({ kind: 'stateful', ...config }), diff --git a/src/ui.ts b/src/ui.ts index 4237aee..a6d6016 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -4,12 +4,13 @@ export type UIValue = | { kind: 'text', content: string, color?: string } | { kind: 'row', children: UIValue[], gap: number } | { kind: 'column', children: UIValue[], gap: number } - | { kind: 'clickable', child: UIValue, event: any } | { kind: 'padding', child: UIValue, amount: number } | { kind: 'positioned', x: number, y: number, child: UIValue } | { kind: 'opacity', child: UIValue, opacity: number } | { kind: 'clip', child: UIValue, w: number, h: number } | { kind: 'stack', children: UIValue[] } + | { kind: 'clickable', child: UIValue, onClick: any } + | { kind: 'scrollable', child: UIValue, w: number, h: number, scrollX: number, scrollY: number, onScroll: any } | { kind: 'stateful', key: string, focusable: boolean, init: any, update: any, view: any } type ClickRegion = { @@ -17,11 +18,21 @@ type ClickRegion = { y: number; width: number; height: number; - event: any; + onClick: any; }; let clickRegions: ClickRegion[] = []; +type ScrollRegion = { + x: number; + y: number; + width: number; + height: number; + onScroll: any; +}; + +let scrollRegions: ScrollRegion[] = []; + export function render(ui: UIValue, canvas: HTMLCanvasElement) { const ctx = canvas.getContext('2d'); if (ctx) { @@ -32,6 +43,7 @@ export function render(ui: UIValue, canvas: HTMLCanvasElement) { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); clickRegions = []; + scrollRegions = []; renderUI(ui, ctx, 0, 0); } } @@ -88,7 +100,7 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb case 'row': { let offsetX = 0; for (const child of ui.children) { - const size = measure(child); + // const size = measure(child); renderUI(child, ctx, x + offsetX, y); offsetX += measure(child).width + (ui.gap || 0); } @@ -106,11 +118,22 @@ function renderUI(ui: UIValue, ctx: CanvasRenderingContext2D, x: number, y: numb case 'clickable': { const size = measure(ui.child); - clickRegions.push({ x, y, width: size.width, height: size.height, event: ui.event }) + clickRegions.push({ x, y, width: size.width, height: size.height, onClick: ui.onClick }) renderUI(ui.child, ctx, x, y); break; } + case 'scrollable': { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, ui.w, ui.h); + ctx.clip(); + renderUI(ui.child, ctx, x - ui.scrollX, y - ui.scrollY); + ctx.restore(); + scrollRegions.push({ x, y, width: ui.w, height: ui.h, onScroll: ui.onScroll }) + break; + } + case 'padding': renderUI(ui.child, ctx, x + ui.amount, y + ui.amount); break; @@ -187,6 +210,9 @@ export function _measure(ui: UIValue): { width: number, height: number } { case 'clickable': return measure(ui.child); + case 'scrollable': + return { width: ui.w, height: ui.h }; + case 'opacity': return measure(ui.child); @@ -223,14 +249,14 @@ export function _measure(ui: UIValue): { width: number, height: number } { } } -export function hitTest(x: number, y: number): { event: any, relativeX: number, relativeY: number } | null { +export function hitTest(x: number, y: number): { onClick: any, relativeX: number, relativeY: number } | null { for (let i = clickRegions.length - 1; i >= 0; i--) { const region = clickRegions[i]; if (x >= region.x && x < region.x + region.width && y >= region.y && y < region.y + region.height) { return { - event: region.event, + onClick: region.onClick, relativeX: x - region.x, relativeY: y - region.y, }; @@ -238,3 +264,14 @@ export function hitTest(x: number, y: number): { event: any, relativeX: number, } return null; } + +export function scrollHitTest(x: number, y: number): { onScroll: any } | null { + for (let i = scrollRegions.length - 1; i >= 0; i--) { + const region = scrollRegions[i]; + if (x >= region.x && x < region.x + region.width && + y >= region.y && y < region.y + region.height) { + return { onScroll: region.onScroll }; + } + } + return null; +}