Adding Stateful, giving up on elm style purity, components get their own state

master
Dustin Swan 1 day ago
parent 9d1b079361
commit a9afb03694
Signed by: dustinswan
GPG Key ID: 30D46587E2100467

@ -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();
}

@ -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,
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
}
]
}

@ -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…
Cancel
Save