UNPKG

@thi.ng/imgui

Version:

Immediate mode GUI with flexible state handling & data only shape output

403 lines (402 loc) 10.1 kB
import { set2 } from "@thi.ng/vectors/set"; import { DEFAULT_THEME, Key, KeyModifier, MouseButton, NONE } from "./api.js"; const defGUI = (opts) => new IMGUI(opts); class IMGUI { attribs; layers; mouse; buttons; key; modifiers; prevMouse; prevButtons; prevKey; prevModifiers; hotID; activeID; focusID; lastID; cursor; t0; time; draw; currIDs; prevIDs; themeStack; disabledStack; resources; states; sizes; constructor(opts) { this.mouse = [-1e3, -1e3]; this.prevMouse = [-1e3, -1e3]; this.key = this.prevKey = ""; this.buttons = this.prevButtons = this.modifiers = this.prevModifiers = 0; this.hotID = this.activeID = this.focusID = this.lastID = ""; this.currIDs = /* @__PURE__ */ new Set(); this.prevIDs = /* @__PURE__ */ new Set(); this.resources = /* @__PURE__ */ new Map(); this.sizes = /* @__PURE__ */ new Map(); this.states = /* @__PURE__ */ new Map(); this.layers = [[], []]; this.attribs = {}; this.cursor = "default"; this.disabledStack = [false]; this.setTheme(opts.theme || {}); this.draw = true; this.t0 = Date.now(); } get theme() { const stack = this.themeStack; return stack[stack.length - 1]; } get disabled() { const stack = this.disabledStack; return stack[stack.length - 1]; } /** * Clears all shape layers and resets theme / disabled stacks. */ clear() { this.layers[0].length = 0; this.layers[1].length = 0; this.themeStack.length = 1; this.disabledStack.length = 1; } /** * Sets mouse position and current mouse button flags (i.e. * `MouseEvent.buttons`). * * @param p - * @param buttons - */ setMouse(p, buttons) { set2(this.prevMouse, this.mouse); set2(this.mouse, p); this.prevButtons = this.buttons; this.buttons = buttons; return this; } /** * Sets internal key state from given key event details. * * @param e - */ setKey(e) { if (e.type === "keydown") { this.prevKey = this.key; this.key = e.key; } this.prevModifiers = this.modifiers; this.modifiers = ~~e.shiftKey * KeyModifier.SHIFT | ~~e.ctrlKey * KeyModifier.CONTROL | ~~e.metaKey * KeyModifier.META | ~~e.altKey * KeyModifier.ALT; return this; } /** * Merges given theme settings with {@link DEFAULT_THEME} and resets theme * stack. * * @param theme - */ setTheme(theme) { this.themeStack = [{ ...DEFAULT_THEME, ...theme }]; } /** * Merges given theme settings with current theme and pushes result * on theme stack. * * IMPORTANT: Currently IMGUI only supports one font and ignores any * font changes pushed on the theme stack. * * @param theme - */ beginTheme(theme) { const stack = this.themeStack; stack.push({ ...stack[stack.length - 1], ...theme }); } /** * Removes current theme from stack (unless only one theme left). */ endTheme() { __popIfNotLast(this.themeStack); } /** * Applies component function with given theme, then restores * previous theme and returns component result. * * @param theme - * @param component - */ withTheme(theme, component) { this.beginTheme(theme); const res = component(); this.themeStack.pop(); return res; } /** * Pushes given disabled component state flag on stack (default: * true, i.e. disabled). Pass `false` to temporarily enable * components. * * @param disabled - */ beginDisabled(disabled = true) { this.disabledStack.push(disabled); } /** * Removes current disabled flag from stack (unless only one theme left). */ endDisabled() { __popIfNotLast(this.disabledStack); } /** * Applies component function with given disabled flag, then * restores previous disabled state and returns component result. * * @param disabled - * @param component - */ withDisabled(disabled, component) { this.disabledStack.push(disabled); const res = component(); this.disabledStack.pop(); return res; } /** * Sets `focusID` to given `id` if the component can receive focus. * Returns true if component is focused. * * @param id - */ requestFocus(id) { if (this.disabled) return false; if (this.focusID === "" || this.activeID === id) { this.focusID = id; return true; } return this.focusID === id; } /** * Attempts to switch focus to next, or if Shift is pressed, to * previous component. This is meant be called ONLY from component * key handlers. */ switchFocus() { this.focusID = this.isShiftDown() ? this.lastID : ""; this.key = ""; } /** * Returns true if left mouse button is pressed. */ isMouseDown() { return (this.buttons & MouseButton.LEFT) > 0; } isShiftDown() { return (this.modifiers & KeyModifier.SHIFT) > 0; } isControlDown() { return (this.modifiers & KeyModifier.CONTROL) > 0; } isMetaDown() { return (this.modifiers & KeyModifier.META) > 0; } isAltDown() { return (this.modifiers & KeyModifier.ALT) > 0; } isPrevMouseDown() { return (this.prevButtons & MouseButton.LEFT) > 0; } isPrevShiftDown() { return (this.prevModifiers & KeyModifier.SHIFT) > 0; } isPrevControlDown() { return (this.prevModifiers & KeyModifier.CONTROL) > 0; } isPrevMetaDown() { return (this.prevModifiers & KeyModifier.META) > 0; } isPrevAltDown() { return (this.prevModifiers & KeyModifier.ALT) > 0; } /** * Prepares IMGUI for next frame: * * - Resets `hotID`, `cursor` * - Resets theme & disabled stacks * - Clears all draw layers * - Updates elapsed time. * * By default all components will emit draw shapes, however this can * be disabled by passing `false` as argument. This is useful for * use cases where the GUI is not updated at high frame rates and so * would require two invocations per update cycle for immediate * visual feedback: * * ``` * gui.begin(false); // update state only, no draw * updateMyGUI(); * gui.end(); * gui.begin(true); // run once more, with draw enabled (default) * updateMyGUI(); * gui.end(); * ``` * * @param draw - */ begin(draw = true) { this.hotID = ""; this.cursor = "default"; this.draw = draw; this.clear(); this.time = (Date.now() - this.t0) * 1e-3; } /** * Performs end-of-frame handling & component cache cleanup. Also * removes cached state and resources of all unused components. */ end() { if (!this.buttons) { this.activeID = ""; } else { if (this.activeID === "") { this.activeID = NONE; this.focusID = NONE; this.lastID = ""; } } this.key === Key.TAB && (this.focusID = ""); this.key = ""; this.gc(); } /** * Garbage collect unused component state / resources. */ gc() { const { currIDs, prevIDs } = this; for (let id of prevIDs) { if (!currIDs.has(id)) { this.resources.delete(id); this.sizes.delete(id); this.states.delete(id); } } this.prevIDs = currIDs; this.currIDs = prevIDs; prevIDs.clear(); } bgColor(hover) { return this.disabled ? this.theme.bgDisabled : hover ? this.theme.bgHover : this.theme.bg; } fgColor(hover) { return this.disabled ? this.theme.fgDisabled : hover ? this.theme.fgHover : this.theme.fg; } textColor(hover) { return this.disabled ? this.theme.textDisabled : hover ? this.theme.textHover : this.theme.text; } focusColor(id) { return this.focusID === id ? this.theme.focus : void 0; } /** * Returns pixel width of given string based on current theme's font * settings. * * IMPORTANT: Currently only monospace fonts are supported. * * @param txt - */ textWidth(txt) { return this.theme.charWidth * txt.length; } /** * Marks given component ID as used and checks `hash` to determine * if the component's resource cache should be cleared. This hash * value should be based on any values (e.g. layout info) which * might invalidate cached resources. * * @param id - * @param hash - */ registerID(id, hash) { this.currIDs.add(id); if (this.sizes.get(id) !== hash) { this.sizes.set(id, hash); this.resources.delete(id); } } /** * Attempts to retrieve cached resource for given component `id` and * resource `hash`. If unsuccessful, calls resource `ctor` function * to create it, caches result and returns it. * * {@link IMGUI.registerID} * * @param id - * @param hash - * @param ctor - */ resource(id, hash, ctor) { let res; let c = this.resources.get(id); !c && this.resources.set(id, c = /* @__PURE__ */ new Map()); return c.get(hash) || (c.set(hash, res = ctor()), res); } /** * Attempts to retrieve cached component state for given `id`. If * unsuccessful, calls state `ctor` function, caches result and * returns it. * * @param id - * @param ctor - */ state(id, ctor) { let res = this.states.get(id); return res !== void 0 ? res : (this.states.set(id, res = ctor()), res); } /** * Stores / overrides given local state value for component `id` in * cache. * * @param id - * @param state - */ setState(id, state) { this.states.set(id, state); } /** * Sets cursor property to given `id`. This setting is cleared at * the beginning of each frame (default value: "default"). * * @param id - */ setCursor(id) { this.cursor = id; } add(...els) { this.layers[0].push(...els); } addOverlay(...els) { this.layers[1].push(...els); } /** * Returns hiccup representation of all shapes/text primitives * created by components in the current frame. */ toHiccup() { return [ "g", { font: this.theme.font }, ...this.layers[0], ...this.layers[1] ]; } } const __popIfNotLast = (stack) => stack.length > 1 && stack.pop(); export { IMGUI, defGUI };