Adding Stateful, giving up on elm style purity, components get their own state
This commit is contained in:
parent
9d1b079361
commit
a9afb03694
6 changed files with 291 additions and 43 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
167
src/runtime.ts
167
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<string, ComponentInstance>();
|
||||
|
||||
// Focus tracking
|
||||
let focusedComponentKey: string | null = null;
|
||||
let focusableComponents: Map<string, { x: number, y: number, w: number, h: number }> = 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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
textInput {
|
||||
key = "email",
|
||||
initialValue = "",
|
||||
w = 300,
|
||||
h = 40
|
||||
},
|
||||
textInput.view state.password {
|
||||
focused = state.focusedInput == "password",
|
||||
onFocus = FocusPassword,
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue