diff --git a/src/parser.ts b/src/parser.ts index 724bcb2..ed18b02 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -147,7 +147,7 @@ export class Parser { } // Let - if (this.current().kind === 'ident' && this.peek().kind === 'equals') { + if ((this.current().kind === 'ident' || this.current().kind === 'underscore') && this.peek().kind === 'equals') { return this.parseLet(); } diff --git a/src/runtime.ts b/src/runtime.ts index cabe77e..1fbd280 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -13,6 +13,152 @@ export type App = { export function runApp(app: App, canvas: HTMLCanvasElement, source: string) { let state = app.init; + type ComponentInstance = { + state: Value; + update: Value; + view: Value; + }; + + const componentInstances = new Map(); + + // Focus tracking + let focusedComponentKey: string | null = null; + let focusableComponents: Map = new Map(); + + function setFocus(componentKey: string | null) { + if (focusedComponentKey === componentKey) return; + + const oldFocus = focusedComponentKey; + focusedComponentKey = componentKey; + + // Blur event to the previous + if (oldFocus && componentInstances.has(oldFocus)) { + handleComponentEvent(oldFocus, { + kind: 'constructor', + name: 'Blurred', + args: [] + }); + } + + // Focus event to the new + if (componentKey && componentInstances.has(componentKey)) { + handleComponentEvent(componentKey, { + kind: 'constructor', + name: 'Focused', + args: [] + }); + } + + rerender(); + } + + function handleComponentEvent(componentKey: string, event: Value) { + const instance = componentInstances.get(componentKey); + if (!instance) return; + + if (instance.update.kind !== 'closure') + throw new Error('Component update must be a closure'); + + try { + const callEnv = new Map(instance.update.env); + callEnv.set(instance.update.params[0], instance.state); + callEnv.set(instance.update.params[1], event); + const result = evaluate(instance.update.body, callEnv, source); + + if (result.kind !== 'record') + throw new Error('Component update must return { state, emit }'); + + const newState = result.fields.state; + const emitList = result.fields.emit; + + instance.state = newState; + + if (emitList && emitList.kind === 'list') { + for (const event of emitList.elements) { + handleEvent(event); + } + } + } catch(error) { + if (error instanceof CGError) { + console.error(error.format()); + } else { + throw error; + + } + } + } + + function expandStateful(ui: UIValue, path: number[]): UIValue { + switch (ui.kind) { + case 'stateful': { + const fullKey = [...path, ui.key].join('.'); + + let instance = componentInstances.get(fullKey); + if (!instance) { + // first time, create it + if (ui.init.kind !=='record') + throw new Error('Stateful init must be a record'); + + instance = { + state: ui.init, + update: ui.update, + view: ui.view + }; + componentInstances.set(fullKey, instance); + } + + if (instance.view.kind !== 'closure') + throw new Error('Stateful view must be a closure'); + + const callEnv = new Map(instance.view.env); + callEnv.set(instance.view.params[0], instance.state); + const viewResult = evaluate(instance.view.body, callEnv, source); + let viewUI = valueToUI(viewResult); + + if (ui.focusable) { + viewUI = { + kind: 'clickable', + child: viewUI, + event: { + kind: 'constructor', + name: 'Focus', + args: [{ kind: 'string', value: fullKey }] + } + }; + } + + return expandStateful(viewUI, path); + } + + case 'stack': + case 'row': + case 'column': { + return { + ...ui, + children: ui.children.map((child, i) => + expandStateful(child, [...path, i]) + ) + } + } + + case 'clickable': + case 'padding': + case 'positioned': + case 'opacity': + case 'clip': { + return { + ...ui, + child: expandStateful((ui as any).child, [...path, 0]) + }; + } + + default: + // leaf nodes + return ui; + } + + } + function setupCanvas() { const dpr = window.devicePixelRatio || 1; @@ -43,8 +189,9 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) { callEnv.set(app.view.params[1], viewport); const uiValue = evaluate(app.view.body, callEnv, source); const ui = valueToUI(uiValue); + const expandedUI = expandStateful(ui, []); - render(ui, canvas); + render(expandedUI, canvas); } catch (error) { if (error instanceof CGError) { console.error(error.format()); @@ -55,6 +202,13 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) { } function handleEvent(event: Value) { + if (event.kind === 'constructor' && event.name === 'Focus') { + if (event.args.length > 0 && event.args[0].kind === 'string') { + setFocus(event.args[0].value); + return; + } + } + if (app.update.kind !== 'closure') throw new Error('update must be a function'); @@ -95,7 +249,9 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) { if (hitResult) { const { event, relativeX, relativeY } = hitResult; - if (event.kind === 'constructor') { + if (event.kind === 'constructor' && event.name === 'Focus') { + handleEvent(event); + } else if (event.kind === 'constructor') { const eventWithCoords: Value = { kind: 'constructor', name: event.name, @@ -131,7 +287,12 @@ export function runApp(app: App, canvas: HTMLCanvasElement, source: string) { } } - handleEvent(event); + if (focusedComponentKey) { + handleComponentEvent(focusedComponentKey, event); + + } else { + handleEvent(event); + } e.preventDefault(); }); diff --git a/src/textinput-test.cg b/src/textinput-test.cg index 48f2f9e..ede3515 100644 --- a/src/textinput-test.cg +++ b/src/textinput-test.cg @@ -1,35 +1,11 @@ -routeKeyToFocused = state event \ - (state.focusedInput == "email" - | True \ - newInputState = textInput.update state.email event; - state.{ email = newInputState } - | False \ - newInputState = textInput.update state.password event; - state.{ password = newInputState }); - -init = { - focusedInput = "email", - email = textInput.init "", - password = textInput.init "" -}; +init = {}; update = state event \ event - | FocusEmail coords \ ( - newState = state.{ focusedInput = "email" }; - newInputState = textInput.update state.email (Clicked coords); - newState.{ email = newInputState } - ) + | FocusEmail coords \ state.{ focusedInput = "email" } + | FocusPassword coords \ state.{ focusedInput = "password" } - | FocusPassword coords \ ( - newState = state.{ focusedInput = "password" }; - newInputState = textInput.update state.password (Clicked coords); - newState.{ password = newInputState } - ) + | Noop \ state - | ArrowLeft \ routeKeyToFocused state ArrowLeft - | ArrowRight \ routeKeyToFocused state ArrowRight - | Backspace \ routeKeyToFocused state Backspace - | Char c \ routeKeyToFocused state (Char c) | _ \ state; view = state viewport \ @@ -39,17 +15,23 @@ view = state viewport \ child = Column { gap = 10, children = [ - textInput.view state.email { - focused = state.focusedInput == "email", - onFocus = FocusEmail, - w = 300, - h = 40 - }, - textInput.view state.password { - focused = state.focusedInput == "password", - onFocus = FocusPassword, + textInput { + key = "email", + initialValue = "", w = 300, - h = 40 + h = 40, + onChange = text \ Noop, + focused = True, + onFocus = Noop + + # focused = state.focusedInput == "email", + # onFocus = FocusEmail, + # }, + # textInput.view state.password { + # focused = state.focusedInput == "password", + # onFocus = FocusPassword, + # w = 300, + # h = 40 } ] } diff --git a/src/types.ts b/src/types.ts index 2bc2e4e..9652ef2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,5 +58,6 @@ export type UIValue = | { kind: 'clip', child: UIValue, w: number, h: number } | { kind: 'stack', children: UIValue[] } | { kind: 'text-input', value: string, placeholder: string, x: number, y: number, w: number, h: number, focused: boolean, onInput: Value, onSubmit: Value } + | { kind: 'stateful', key: string, focusable: boolean, init: Value, update: Value, view: Value } export type Value = IntValue | FloatValue | StringValue | Closure | ListValue | RecordValue | ConstructorValue | NativeFunction; diff --git a/src/ui-components.cg b/src/ui-components.cg index 675d03b..ca675c2 100644 --- a/src/ui-components.cg +++ b/src/ui-components.cg @@ -50,7 +50,93 @@ findCursorPos = text clickX scrollOffset inputPadding \ adjustedX = clickX + scrollOffset - inputPadding; findPosHelper text adjustedX 0; -textInput = { +textInput = config \ Stateful { + key = config.key, + focusable = True, + + # init : State + init = { text = config.initialValue, cursorPos = 0, scrollOffset = 0 }, + + # update : State \ Event \ State + update = state event \ event + | ArrowLeft \ ( + newCursorPos = max 0 (state.cursorPos - 1); + newScroll = calcScrollOffset state.text newCursorPos state.scrollOffset 284; + newState = state.{ text = state.text, cursorPos = newCursorPos, scrollOffset = newScroll }; + return { state = newState, emit = [] } + ) + + | ArrowRight \ ( + newCursorPos = min (len state.text) (state.cursorPos + 1); + newScroll = calcScrollOffset state.text newCursorPos state.scrollOffset 284; + newState = state.{ text = state.text, cursorPos = newCursorPos, scrollOffset = newScroll }; + return { state = newState, emit = [] } + ) + + | Backspace \ ( + newText = deleteChar state.text state.cursorPos; + newCursorPos = max 0 (state.cursorPos - 1); + newScroll = calcScrollOffset newText newCursorPos state.scrollOffset 284; + newState = state.{ text = newText, cursorPos = newCursorPos, scrollOffset = newScroll }; + { state = newState, emit = [config.onChange newText] } + ) + + | Char c \ ( + _ = debug c; + newText = insertChar state.text state.cursorPos c; + newCursorPos = state.cursorPos + 1; + newScroll = calcScrollOffset newText newCursorPos state.scrollOffset 284; + newState = state.{ text = newText, cursorPos = newCursorPos, scrollOffset = newScroll }; + { state = newState, emit = [config.onChange newText] } + ) + + | Clicked coords \ ( + newCursorPos = findCursorPos state.text coords.x state.scrollOffset 8; + newScroll = calcScrollOffset state.text newCursorPos state.scrollOffset 284; + newSatte = state.{ text = state.text, cursorPos = newCursorPos, scrollOffset = newScroll }; + { state = newState, emit = [] } + ) + + | Focused \ { state = state, emit = [] } + | Blurred \ { state = state, emit = [] } + | _ \ { state = state, emit = [] }, + + view = state \ + textBeforeCursor = slice state.text 0 state.cursorPos; + cursorX = measureText textBeforeCursor; + padding = 8; + + Clip { + w = config.w, + h = config.h, + child = Clickable { + event = config.onFocus, + child = + Stack { + children = [ + Rect { w = config.w, h = config.h, color = "rgba(240,240,240,0.9)", radius = 4 }, + + Positioned { + x = 8 - state.scrollOffset, + y = 8, + child = Text { content = state.text, x = 0, y = 17 } + }, + + (config.focused + | True \ Positioned { + x = 8 + cursorX - state.scrollOffset, + y = 8, + child = Rect { w = 2, h = 24, color = "black" } + } + | _ \ Rect { w = 0, h = 0, color = "transparent" }) + ] + } + } + } +}; + + +textInputOLD = { # init : String \ State init = text \ { text = text, cursorPos = 0, scrollOffset = 0 }, diff --git a/src/valueToUI.ts b/src/valueToUI.ts index 25f9c9a..354e7f2 100644 --- a/src/valueToUI.ts +++ b/src/valueToUI.ts @@ -137,6 +137,24 @@ export function valueToUI(value: Value): UIValue { }; } + case 'Stateful': { + const { key, focusable, init, update, view } = fields; + + if (key.kind !== 'string') + throw new Error('Stateful key must be a string'); + + const isFocusable = focusable?.kind === 'constructor' && focusable.name === 'True'; + + return { + kind: 'stateful', + key: key.value, + focusable: isFocusable, + init, + update, + view + }; + } + default: throw new Error(`Unknown UI constructor: ${value.name}`); }