blob: 0728d2d1022be5b34dab3edd83f522f925af3fd3 [file] [log] [blame] [edit]
/**
* A Tweak is a "mutable number"—a value that coerces to a number in most
* contexts but can be mutated at runtime to allow for easy experimentation.
* For more information, see the {@link tweak} function.
*/
export type Tweak = number & _Tweak;
interface _Tweak {
get(): number;
set(v: number): void;
valueOf(): number;
toString(): string;
[Symbol.toPrimitive](hint: "number" | "string" | "default"): number | string;
onChange(func: OnTweakChangeFn): void;
initial: number;
name: string;
min: number;
max: number;
step: number;
}
export interface TweakOptions {
/**
* The minimum value for the Tweak's slider. Defaults to 0.
*/
min?: number,
/**
* The maximum value for the Tweak's slider. Defaults to 100.
*/
max?: number,
/**
* The step value for the Tweak's slider. Defaults to 1.
*/
step?: number,
/**
* The Tweaks object to attach the Tweak to. Defaults to `globalTweaks`.
*/
tweaksObject?: Tweaks,
}
export type OnTweakChangeFn = (newValue: number) => void;
/**
* Creates a `Tweak` and adds UI for it to be modified.
*
* A Tweak is a "mutable number"—a value that coerces to a number in most
* contexts but can be mutated at runtime to allow for easy experimentation.
* Tweaks are automatically registered into a global {@link Tweaks} object and
* given UI for changing its value. The experience is inspired by
* [Dear ImGui](https://github.com/ocornut/imgui).
*
* A Tweak can be created using the {@link tweak} function:
*
* const TWEAKY_CONSTANT = tweak("Tweak Name", 3);
*
* This will create a Tweak with an initial value of 3, which can be used
* anywhere a numeric constant would be used. Other options are available via
* an optional third parameter, such as the ability to control the min and max
* values or to register the Tweak with a different Tweaks object. See
* {@link TweakOptions} for details.
*
* Two more steps are required to set your project up for tweaks:
*
* 1. Add the Tweaks UI to your page:
*
* document.querySelector("body").appendChild(window.tweaks.container);
*
* 2. Listen for changes to Tweaks and refresh your app accordingly:
*
* window.addEventListener("tweak", () => {
* updateAll();
* });
*
* Tweaks use `valueOf` and `Symbol.toPrimitive` so that they behave like
* numbers when used in expressions. We do some TypeScript shenanigans to allow
* the Tweak type to be used anywhere numbers are used. However, one big
* annoyance is that exotic objects are always truthy, and the
* [ToBoolean](https://262.ecma-international.org/8.0/#sec-toboolean) operation
* doesn't allow for any hooks to modify this behavior. Attempting to use a
* Tweak as a boolean value will therefore not work as expected. The easiest
* workaround is to put a unary plus in front of the Tweak when using it, e.g.
* `if (+DEBUG)`, in order to explicitly convert it to a number before
* assessing its truthiness.
*/
export function tweak(name: string, initial: number, options: TweakOptions = {}): Tweak {
let value = initial;
const callbacks: OnTweakChangeFn[] = [];
const t: _Tweak = {
get(): number {
return value;
},
set(v: number) {
value = v;
for (const func of callbacks) {
func(value);
}
},
valueOf(): number {
return value;
},
toString(): string {
return String(value);
},
[Symbol.toPrimitive](hint: "number" | "string" | "default"): number | string {
if (hint === "string") {
return String(value);
}
return value;
},
onChange(func: OnTweakChangeFn) {
callbacks.push(func);
},
initial: initial,
name: name,
min: options.min ?? 0,
max: options.max ?? 100,
step: options.step ?? 1,
};
return (options.tweaksObject ?? globalTweaks).add(t as Tweak);
}
export interface TweaksOptions {
/**
* The HTML element to render Tweaks into.
*/
container: HTMLElement,
}
export type OnTweakFn = (t: Tweak) => void;
/**
* A collection of Tweaks. By default, Tweaks will be registered with the
* pre-existing {@link globalTweaks} object, which dispatches a "tweak" event
* to `window` whenever a value is tweaked. Most of the time you should use
* that instead of creating a new Tweaks object yourself.
*
* Each instance of Tweaks requires an HTML element to render into; you are
* responsible for placing this container somewhere in the DOM. Subscribe to
* changes by calling {@link onTweak}.
*/
export class Tweaks {
container: HTMLElement;
tweaks: Tweak[];
callbacks: OnTweakFn[];
constructor(options: TweaksOptions) {
this.container = options.container;
this.tweaks = [];
this.callbacks = [];
}
/**
* Adds a Tweak to this object. Not intended to be called directly; instead,
* just call {@link tweak} with the `tweaksObject` option.
*/
add(tweak: Tweak): Tweak {
// Do not add the same tweak twice.
const existing = this.tweaks.find(t => t.name === tweak.name);
if (existing) {
return existing;
}
this.tweaks.push(tweak);
const el = document.createElement("div");
this.container.appendChild(el);
el.style.display = "flex";
el.style.alignItems = "center";
el.style.justifyContent = "end";
el.style.gap = "0.5rem";
const safename = tweak.name.replace(/[a-zA-Z0-9]/g, "_");
const label = document.createElement("label");
el.appendChild(label);
label.innerText = tweak.name;
label.htmlFor = `tweak-${safename}-input`;
const input = document.createElement("input");
el.appendChild(input);
input.type = "number";
input.value = String(tweak);
input.id = `tweak-${safename}-input`;
input.style.width = "4rem";
input.addEventListener("input", () => {
tweak.set(input.valueAsNumber);
});
const range = document.createElement("input");
el.appendChild(range);
range.type = "range";
range.value = String(tweak);
range.min = String(tweak.min);
range.max = String(tweak.max);
range.step = tweak.step === 0 ? "any" : String(tweak.step);
range.addEventListener("input", () => {
tweak.set(range.valueAsNumber);
});
const reset = document.createElement("button");
el.appendChild(reset);
reset.innerText = "Reset";
reset.disabled = tweak.get() === tweak.initial;
reset.addEventListener("click", () => {
tweak.set(tweak.initial);
});
tweak.onChange(v => {
input.value = String(v);
range.value = String(v);
reset.disabled = tweak.get() === tweak.initial;
for (const func of this.callbacks) {
func(tweak);
}
});
return tweak;
}
onTweak(func: OnTweakFn) {
this.callbacks.push(func);
}
}
const globalContainer = document.createElement("div");
globalContainer.classList.add("tweaks-panel");
/**
* The default {@link Tweaks} object used by the {@link tweak} function.
* Dispatches a "tweak" event to `window` whenever a value is tweaked. This
* object can also be accessed via `window.tweaks`.
*/
export const globalTweaks = new Tweaks({ container: globalContainer });
globalTweaks.onTweak(t => {
window.dispatchEvent(new CustomEvent("tweak", { detail: t }));
});
// @ts-ignore
window.tweaks = globalTweaks;
// Type system tests
const testContainer = document.createElement("div");
const testTweaks = new Tweaks({ container: testContainer });
let testTweak = tweak("Test Value", 3, { tweaksObject: testTweaks });
testTweak.set(4);
// @ts-expect-error
testTweak = 4;