├── src ├── context.ts ├── elements.ts ├── globalState.ts ├── menu.ts ├── ImHUI.ts ├── colors.ts ├── button.ts ├── canvas2D.ts ├── child.ts ├── utils.ts ├── plotLines.ts ├── inputText.ts ├── sliderFloat.ts ├── colorEdit.ts ├── valueDrag.ts ├── text.ts ├── main.ts ├── core.ts ├── background.ts ├── window.ts └── types │ └── ResizeObserver.d.ts ├── index.css ├── tsconfig.json ├── .gitignore ├── index.html ├── package.json ├── 3rdparty ├── scrollbars.js └── scrollbars.css ├── .github └── workflows │ └── deploy-to-gh-pages.yml ├── LICENSE.md ├── TODO.md ├── DEVNOTES.md ├── README.md └── ImHUI.css /src/context.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/elements.ts: -------------------------------------------------------------------------------- 1 | import {element} from './core.js'; 2 | 3 | export function separator() { 4 | element('hr'); 5 | } 6 | -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | .value-line { 2 | display:flex; 3 | align-items: center; 4 | } 5 | .value-line>*:nth-child(1) { 6 | flex: 1 1 auto; 7 | } -------------------------------------------------------------------------------- /src/globalState.ts: -------------------------------------------------------------------------------- 1 | import { 2 | queueUpdate, 3 | } from './core.js'; 4 | 5 | const darkMatcher = window.matchMedia("(prefers-color-scheme: dark)"); 6 | darkMatcher.addEventListener('change', () => { 7 | queueUpdate(); 8 | }); 9 | 10 | export function isDarkMode(): boolean { 11 | return darkMatcher.matches; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "lib": ["ES2020", "DOM"], 5 | "noImplicitAny": true, 6 | "removeComments": false, 7 | "preserveConstEnums": true, 8 | "target": "ESNext", 9 | "outDir": "build", 10 | "sourceMap": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "**/*.spec.ts"] 14 | } -------------------------------------------------------------------------------- /src/menu.ts: -------------------------------------------------------------------------------- 1 | export function beginMenuBar(): boolean { 2 | return true; 3 | } 4 | 5 | export function endMenuBar() { 6 | 7 | } 8 | 9 | export function beginMenu(title: string): boolean { 10 | return true; 11 | } 12 | 13 | export function endMenu() { 14 | 15 | } 16 | 17 | export function menuItem(title: string, shortcut: string): boolean { 18 | return false; 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/ImHUI.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export * from './button.js'; 4 | export * from './canvas2D.js'; 5 | export * from './child.js'; 6 | export * from './core.js'; 7 | export * from './colorEdit.js'; 8 | export * from './elements.js'; 9 | export * from './globalState.js' 10 | export * from './inputText.js'; 11 | export * from './menu.js'; 12 | export * from './plotLines.js'; 13 | export * from './sliderFloat.js'; 14 | export * from './text.js'; 15 | export * from './valueDrag.js'; 16 | export * from './window.js'; 17 | 18 | -------------------------------------------------------------------------------- /src/colors.ts: -------------------------------------------------------------------------------- 1 | import {isDarkMode} from './globalState.js'; 2 | 3 | const darkColors: Record = { 4 | lines: 'white', 5 | } 6 | const lightColors: Record = { 7 | lines: 'black', 8 | }; 9 | 10 | export function setColors( 11 | newLightColors: Record, 12 | newDarkColors?: Record) { 13 | Object.assign(lightColors, newLightColors); 14 | Object.assign(darkColors, newDarkColors || newLightColors); 15 | } 16 | 17 | export function getColors(): Record { 18 | return isDarkMode() ? darkColors : lightColors; 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ImHUI", 3 | "version": "0.0.1", 4 | "description": "", 5 | "scripts": { 6 | "build": "npm run build-tsc && npm run copy", 7 | "build-ci": "npm run build && npm run copy-src", 8 | "build-tsc": "tsc", 9 | "watch": "npm run copy && tsc --watch", 10 | "copy": "ldcp index.html build && ldcp ImHUI.css build && ldcp index.css build && ldcp -R 3rdparty/ build/3rdparty/", 11 | "copy-src": "ldcp -R src/ build/src/" 12 | }, 13 | "keywords": [ 14 | "UI", 15 | "ImGUI", 16 | "ImHUI", 17 | "HTML", 18 | "JavaScript", 19 | "TypeScript" 20 | ], 21 | "author": "Gregg Tavares", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "ldcp": "^0.1.6", 25 | "typescript": "^4.1.5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /3rdparty/scrollbars.js: -------------------------------------------------------------------------------- 1 | //import './scrollbars.css'; 2 | 3 | /* 4 | * Scrollbar Width Test 5 | * Adds `layout-scrollbar-obtrusive` class to body if scrollbars use up screen real estate 6 | */ 7 | const parent = document.createElement("div"); 8 | parent.setAttribute("style", "width:30px;height:30px;"); 9 | parent.classList.add('scrollbar-test'); 10 | 11 | const child = document.createElement("div"); 12 | child.setAttribute("style", "width:100%;height:40px"); 13 | parent.appendChild(child); 14 | document.body.appendChild(parent); 15 | 16 | // Measure the child element, if it is not 17 | // 30px wide the scrollbars are obtrusive. 18 | const scrollbarWidth = 30 - parent.firstChild.clientWidth; 19 | if(scrollbarWidth) { 20 | document.body.classList.add("layout-scrollbar-obtrusive"); 21 | } 22 | 23 | document.body.removeChild(parent); -------------------------------------------------------------------------------- /src/button.ts: -------------------------------------------------------------------------------- 1 | import {e} from './utils.js'; 2 | import {Node, queueUpdate, context} from './core.js'; 3 | 4 | class ButtonNode extends Node { 5 | #result: boolean = false; 6 | #prompt: string; 7 | 8 | constructor(str: string) { 9 | super('button'); 10 | this.elem.addEventListener('click', () => { 11 | this.#result = true; 12 | queueUpdate(); 13 | }); 14 | } 15 | 16 | update(str: string): boolean { 17 | if (this.#prompt !== str) { 18 | this.#prompt = str; 19 | this.elem.textContent = str; 20 | } 21 | const result = this.#result; 22 | this.#result = false; 23 | return result; 24 | } 25 | } 26 | 27 | export function button(str: string) : boolean { 28 | const button = context.getExistingNodeOrRemove(ButtonNode, str); 29 | return button.update(str); 30 | } -------------------------------------------------------------------------------- /src/canvas2D.ts: -------------------------------------------------------------------------------- 1 | import { 2 | e, 3 | } from './utils.js'; 4 | import { 5 | context, 6 | Node, 7 | queueUpdate, 8 | queueUpdateBecausePreviousUsagesMightBeStale, 9 | } from './core.js' 10 | 11 | class CanvasNode extends Node { 12 | #canvasElem: HTMLCanvasElement; 13 | #ctx: CanvasRenderingContext2D; 14 | 15 | constructor() { 16 | super('canvas'); 17 | this.setClassName('fill-space'); 18 | this.#canvasElem = this.elem; 19 | this.#ctx =this.#canvasElem.getContext('2d'); 20 | const resizeObserver = new ResizeObserver(queueUpdate); 21 | resizeObserver.observe(this.elem); 22 | } 23 | 24 | update() { 25 | return this.#ctx; 26 | } 27 | } 28 | 29 | export function canvas2D(): CanvasRenderingContext2D { 30 | const canvasNode = context.getExistingNodeOrRemove(CanvasNode); 31 | return canvasNode.update(); 32 | } -------------------------------------------------------------------------------- /.github/workflows/deploy-to-gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🍔🍟🥤 12 | uses: actions/checkout@v2.3.1 13 | with: 14 | persist-credentials: false 15 | 16 | - name: Use Node.js 😂 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: '14.x' 20 | 21 | - name: Install and Build 🏭 22 | run: | 23 | npm i 24 | npm run build-ci 25 | 26 | - name: Deploy 📦 27 | if: ${{ github.event_name == 'push' }} 28 | uses: JamesIves/github-pages-deploy-action@3.6.2 29 | with: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | BRANCH: gh-pages 32 | FOLDER: build 33 | -------------------------------------------------------------------------------- /src/child.ts: -------------------------------------------------------------------------------- 1 | import {e} from './utils.js'; 2 | import {Node, Context, context} from './core.js'; 3 | import {text} from './text.js'; 4 | 5 | export class BasicWrapperNode extends Node { 6 | #context: Context; 7 | 8 | constructor() { 9 | super('div'); 10 | this.#context = new Context(this.elem, this.end); 11 | } 12 | 13 | begin(className: string) { 14 | this.setClassName(className); 15 | Context.pushContext(this.#context); 16 | } 17 | 18 | end = () => { 19 | Context.popContext(); 20 | } 21 | } 22 | 23 | export function beginWrapper(className: string) { 24 | const node = context.getExistingNodeOrRemove(BasicWrapperNode); 25 | node.begin(className); 26 | } 27 | 28 | export function endWrapper() { 29 | context.finish(); 30 | } 31 | 32 | export function beginChild(id: string) { 33 | beginWrapper('child layout-scrollbar'); 34 | } 35 | 36 | export function endChild() { 37 | endWrapper(); 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gregg Tavares 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TO DO 2 | 3 | * Clean up unused elements 4 | * Stop using details/summary 5 | * Stop using css:resize 6 | * how to specify window size? 7 | * how to specify row height 8 | * remember window pos/size (as it is we're letting css: resize 9 | handle it but when a window gets resized we lose that info. 10 | * should window height be auto fit for content 11 | * how to specify that 12 | * how to specify min/max window size 13 | * Check events don't bleed through windows 14 | * Figure out mouse event support 15 | * Fix requirement for node to have an element 16 | * Try to split window into components 17 | * Implement dropdown menus 18 | * Figure out slider basics 19 | * how to specify precision 20 | * Make a slider with number input (built from parts) 21 | * make an `element(tag, attrs)` type (lowest level?) 22 | * Make `canvas` from `canvasNode` 23 | * see if we can get rid of `update` 24 | * figure out what to do about the log example (long lists) 25 | * consider a `canvas` without requiring re-drawing 26 | maybe that should be the default 27 | * consider having a canvas callback 28 | * rename sliderFloat to just slider or range? 29 | * change call ImHUI css to `imhui-...` 30 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function e(tag: string, attrs = {}, children: (HTMLElement|string)[] = []): HTMLElement { 2 | const elem = document.createElement(tag); 3 | for (const [key, value] of Object.entries(attrs)) { 4 | if (typeof value === 'object') { 5 | for (const [k, v] of Object.entries(value)) { 6 | elem[key][k] = v; 7 | } 8 | } else if (elem[key] === undefined) { 9 | elem.setAttribute(key, value); 10 | } else { 11 | elem[key] = value; 12 | } 13 | } 14 | if (Array.isArray(children)) { 15 | for (const child of children) { 16 | if (typeof child === 'string') { 17 | elem.appendChild(document.createTextNode(child)); 18 | } else { 19 | elem.appendChild(child); 20 | } 21 | } 22 | } else { 23 | elem.textContent = children; 24 | } 25 | return elem; 26 | } 27 | 28 | export function clamp(v: number, min: number = 0, max: number = 1): number { 29 | return Math.min(Math.max(v, min), max); 30 | } 31 | 32 | export function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean { 33 | const width = canvas.clientWidth; 34 | const height = canvas.clientHeight; 35 | const needResize = width !== canvas.width || height !== canvas.height; 36 | if (needResize) { 37 | canvas.width = width; 38 | canvas.height = height; 39 | } 40 | return needResize; 41 | } 42 | -------------------------------------------------------------------------------- /src/plotLines.ts: -------------------------------------------------------------------------------- 1 | import {canvas2D} from './canvas2D.js'; 2 | import {beginWrapper, endWrapper} from './child.js'; 3 | import {getColors} from './colors.js'; 4 | import {text} from './text.js'; 5 | import {resizeCanvasToDisplaySize} from './utils.js'; 6 | 7 | export function plotLines( 8 | prompt: string, 9 | points: number[], 10 | scaleMin?: number, 11 | scaleMax?: number, 12 | size?: number[], 13 | ) { 14 | beginWrapper('plot-lines form-line'); 15 | beginWrapper('div'); 16 | const ctx = canvas2D(); 17 | resizeCanvasToDisplaySize(ctx.canvas); 18 | const {width, height} = ctx.canvas; 19 | const colors = getColors(); 20 | ctx.clearRect(0, 0, width, height); 21 | ctx.strokeStyle = colors.lines; 22 | let min = points[0]; 23 | let max = min; 24 | if (scaleMin === undefined || scaleMax === undefined) { 25 | for (let i = 1; i < points.length; ++i) { 26 | const x = points[i]; 27 | min = Math.min(min, x); 28 | max = Math.max(max, x); 29 | } 30 | } 31 | min = scaleMin === undefined ? min : scaleMin; 32 | max = scaleMax === undefined ? max : scaleMax; 33 | ctx.beginPath(); 34 | const range = max - min; 35 | for (let i = 0; i < points.length; ++i) { 36 | ctx.lineTo( 37 | i * width / ((points.length - 1) || 1), 38 | (points[i] - min) * height / range); 39 | } 40 | ctx.stroke(); 41 | endWrapper(); 42 | text(prompt); 43 | endWrapper(); 44 | } -------------------------------------------------------------------------------- /src/inputText.ts: -------------------------------------------------------------------------------- 1 | import {e} from './utils.js'; 2 | import { 3 | context, 4 | Node, 5 | queueUpdate, 6 | queueUpdateBecausePreviousUsagesMightBeStale, 7 | } from './core.js' 8 | import {text} from './text.js'; 9 | import { beginWrapper, endWrapper } from './child.js'; 10 | 11 | class InputTextNode extends Node { 12 | #value: string; 13 | #haveNewValue: boolean = false; 14 | 15 | constructor(value: string) { 16 | super('input'); 17 | const inputElem = this.elem; 18 | inputElem.type = 'text'; 19 | inputElem.addEventListener('input', (e) => { 20 | this.#value = inputElem.value; 21 | this.#haveNewValue = true; 22 | queueUpdate(); 23 | }); 24 | } 25 | 26 | update(value: string): string { 27 | if (this.#haveNewValue) { 28 | this.#haveNewValue = false; 29 | value = this.#value; 30 | queueUpdateBecausePreviousUsagesMightBeStale(); 31 | } else { 32 | if (value !== this.#value) { 33 | this.#value = value; 34 | (this.elem).value = value; 35 | } 36 | } 37 | return value; 38 | } 39 | } 40 | 41 | export function inputTextNode(value: string) { 42 | const node = context.getExistingNodeOrRemove(InputTextNode, value); 43 | return node.update(value); 44 | } 45 | 46 | export function inputText(prompt: string, value: string): string { 47 | beginWrapper('input-text form-line'); 48 | value = inputTextNode(value); 49 | text(prompt); 50 | endWrapper(); 51 | return value; 52 | } 53 | -------------------------------------------------------------------------------- /3rdparty/scrollbars.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | :root { 3 | --scrollbar-size: .375rem; 4 | --scrollbar-minlength: 1.5rem; /* Minimum length of scrollbar thumb (width of horizontal, height of vertical) */ 5 | --scrollbar-ff-width: thin; /* FF-only accepts auto, thin, none */ 6 | --scrollbar-track-color: transparent; 7 | --scrollbar-color: rgba(0,0,0,.2); 8 | --scrollbar-color-hover: rgba(0,0,0,.3); 9 | --scrollbar-color-active: rgb(0,0,0); 10 | } 11 | 12 | /* Use .layout-scrollbar-obtrusive to only use overflow if scrollbars don’t overlay */ 13 | .scrollbar-test, 14 | .layout-cell { 15 | overscroll-behavior: contain; 16 | overflow-y: auto; 17 | -webkit-overflow-scrolling: touch; 18 | -ms-overflow-style: -ms-autohiding-scrollbar; 19 | scrollbar-width: var(--scrollbar-ff-width); 20 | } 21 | 22 | /* This class controls what elements have the new fancy scrollbar CSS */ 23 | .layout-scrollbar { 24 | scrollbar-color: var(--scrollbar-color) var(--scrollbar-track-color); 25 | } 26 | /* Only apply height/width to ::-webkit-scrollbar if is obtrusive */ 27 | .layout-scrollbar-obtrusive .layout-scrollbar::-webkit-scrollbar { 28 | height: var(--scrollbar-size); 29 | width: var(--scrollbar-size); 30 | } 31 | .layout-scrollbar::-webkit-scrollbar-track { 32 | background-color: var(--scrollbar-track-color); 33 | } 34 | .layout-scrollbar::-webkit-scrollbar-thumb { 35 | background-color: var(--scrollbar-color); 36 | border-radius: 3px; 37 | } 38 | .layout-scrollbar::-webkit-scrollbar-thumb:hover { 39 | background-color: var(--scrollbar-color-hover); 40 | } 41 | .layout-scrollbar::-webkit-scrollbar-thumb:active { 42 | background-color: var(--scrollbar-color-active); 43 | } 44 | .scrollbar-test::-webkit-scrollbar-thumb:vertical, 45 | .layout-scrollbar::-webkit-scrollbar-thumb:vertical { 46 | min-height: var(--scrollbar-minlength); 47 | } 48 | .scrollbar-test::-webkit-scrollbar-thumb:horizontal, 49 | .layout-scrollbar::-webkit-scrollbar-thumb:horizontal { 50 | min-width: var(--scrollbar-minlength); 51 | } 52 | 53 | 54 | @media (prefers-color-scheme: dark) { 55 | :root { 56 | --scrollbar-color:#555; 57 | --scrollbar-color-hover: #555; 58 | --scrollbar-color-active: #555; 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /src/sliderFloat.ts: -------------------------------------------------------------------------------- 1 | import {e} from './utils.js'; 2 | import { 3 | context, 4 | Node, 5 | queueUpdate, 6 | queueUpdateBecausePreviousUsagesMightBeStale, 7 | } from './core.js' 8 | import {text} from './text.js'; 9 | import { beginWrapper, endWrapper } from './child.js'; 10 | 11 | class SliderFloatNode extends Node { 12 | #inputElem: HTMLInputElement; 13 | #value: number; 14 | #haveNewValue: boolean = false; 15 | #min: number; 16 | #max: number; 17 | 18 | constructor() { 19 | super('input'); 20 | this.#inputElem = this.elem; 21 | this.#inputElem.type = 'range'; 22 | this.#inputElem.addEventListener('input', (e) => { 23 | this.#value = parseFloat(this.#inputElem.value); 24 | this.#haveNewValue = true; 25 | queueUpdate(); 26 | }); 27 | } 28 | 29 | update(value: number, min: number, max: number): number { 30 | if (this.#haveNewValue) { 31 | this.#haveNewValue = false; 32 | value = this.#value; 33 | queueUpdateBecausePreviousUsagesMightBeStale(); 34 | } else { 35 | if (value !== this.#value) { 36 | this.#value = value; 37 | this.#inputElem.value = value.toString(); 38 | } 39 | } 40 | if (min !== this.#min) { 41 | this.#min = min; 42 | this.#inputElem.min = min.toString(); 43 | this.#inputElem.step = ((this.#max - this.#min) / 1000).toString(); 44 | } 45 | if (max !== this.#max) { 46 | this.#max = max; 47 | this.#inputElem.max = max.toString(); 48 | this.#inputElem.step = ((this.#max - this.#min) / 1000).toString(); 49 | } 50 | return value; 51 | } 52 | } 53 | 54 | export function sliderFloatNode(value: number, min = 0, max = 1): number { 55 | const node = context.getExistingNodeOrRemove(SliderFloatNode); 56 | return node.update(value, min, max); 57 | } 58 | 59 | export function sliderFloat(prompt: string, value: number, min = 0, max = 1): number { 60 | beginWrapper('slider-float form-line'); 61 | beginWrapper('slider-value'); 62 | value = sliderFloatNode(value, min, max); 63 | text(value.toFixed(2)); 64 | endWrapper(); 65 | text(prompt); 66 | endWrapper(); 67 | return value; 68 | } 69 | -------------------------------------------------------------------------------- /src/colorEdit.ts: -------------------------------------------------------------------------------- 1 | import {e} from './utils.js'; 2 | import { 3 | context, 4 | Node, 5 | queueUpdate, 6 | queueUpdateBecausePreviousUsagesMightBeStale, 7 | } from './core.js' 8 | import {valueDrag} from './valueDrag.js'; 9 | import { beginWrapper, endWrapper } from './child.js'; 10 | import { text } from './text.js'; 11 | 12 | export type Color = number[]; 13 | 14 | function gs(obj: any, prop: string) { 15 | return { 16 | get() { return obj[prop]; }, 17 | set(v: any) { obj[prop] = v; } 18 | }; 19 | } 20 | 21 | function isArrayEqual(a1: any[], a2: any[]) { 22 | if (!a1) { 23 | return !a2; 24 | } else if (!a2) { 25 | return false; 26 | } else { 27 | if (a1.length !== a2.length) { 28 | return false; 29 | } 30 | for (let i = 0; i < a1.length; ++i) { 31 | if (a1[i] !== a2[i]) { 32 | return false; 33 | } 34 | } 35 | } 36 | return true; 37 | } 38 | 39 | const rgba = (r: number, g: number, b: number, a: number) => `rgba(${r * 255 | 0},${g * 255 | 0},${b * 255 | 0},${a})` 40 | 41 | class ColorButtonNode extends Node { 42 | #result: boolean = false; 43 | #prompt: string; 44 | #color: Color; 45 | 46 | constructor(str: string, color: Color) { 47 | super('div'); 48 | this.setClassName('color-button'); 49 | this.elem.addEventListener('click', () => { 50 | this.#result = true; 51 | queueUpdate(); 52 | }); 53 | } 54 | 55 | update(str: string, value: Color): boolean { 56 | if (this.#prompt !== str) { 57 | this.#prompt = str; 58 | this.elem.textContent = str; 59 | } 60 | if (!isArrayEqual(value, this.#color)) { 61 | this.#color = value.slice(); 62 | this.elem.style.backgroundColor = rgba(value[0], value[1], value[2], value[3]); 63 | } 64 | const result = this.#result; 65 | this.#result = false; 66 | return result; 67 | } 68 | } 69 | 70 | export function colorButton(str: string, value: Color) : boolean { 71 | const button = context.getExistingNodeOrRemove(ColorButtonNode, str, value); 72 | return button.update(str, value); 73 | } 74 | 75 | export function colorEdit4(prompt: string, value: Color) { 76 | beginWrapper('color-edit-4 form-line') 77 | beginWrapper('color-edit-4-sub') 78 | value[0] = valueDrag('R:', value[0]); 79 | value[1] = valueDrag('G:', value[1]); 80 | value[2] = valueDrag('B:', value[2]); 81 | value[3] = valueDrag('A:', value[3]); 82 | colorButton('', value); 83 | endWrapper(); 84 | text(prompt); 85 | endWrapper(); 86 | } -------------------------------------------------------------------------------- /src/valueDrag.ts: -------------------------------------------------------------------------------- 1 | import { 2 | clamp, 3 | e, 4 | } from './utils.js'; 5 | import { 6 | context, 7 | Node, 8 | queueUpdate, 9 | queueUpdateBecausePreviousUsagesMightBeStale, 10 | } from './core.js' 11 | 12 | class ValueDragNode extends Node { 13 | #prompt: string; 14 | #startValue: number; 15 | #value: number; 16 | #min: number = 0; 17 | #max: number = 0; 18 | #precision: number = 2; 19 | #haveNewValue: boolean = false; 20 | #mouseStartX: number; 21 | #moveRange: number = 100; 22 | 23 | constructor(prompt: string, value: number, min: number = 0, max:number = 1, precision: number = 2) { 24 | super('div'); 25 | this.setClassName('value-drag'); 26 | this.elem.addEventListener('mousedown', (e: MouseEvent) => { 27 | this.#mouseStartX = e.clientX; 28 | this.#startValue = this.#value; 29 | window.addEventListener('mousemove', this.#onMouseMove); 30 | window.addEventListener('mouseup', this.#onMouseUp); 31 | }); 32 | } 33 | 34 | #onMouseMove = (e: MouseEvent) => { 35 | const deltaNorm = (e.clientX - this.#mouseStartX) / this.#moveRange; 36 | const delta = (this.#max - this.#min) * deltaNorm; 37 | const newValue = clamp(this.#startValue + delta, this.#min, this.#max); 38 | this.#value = newValue; 39 | this.#haveNewValue = true; 40 | this._update(); 41 | queueUpdate(); 42 | } 43 | 44 | #onMouseUp = () => { 45 | window.removeEventListener('mousemove', this.#onMouseMove); 46 | window.removeEventListener('mouseup', this.#onMouseUp); 47 | } 48 | 49 | _update() { 50 | this.elem.textContent = `${this.#prompt}${this.#value.toFixed(this.#precision)}` 51 | } 52 | 53 | update(prompt: string, value: number, min: number = 0, max: number = 1, precision: number = 2): number { 54 | if (this.#haveNewValue) { 55 | this.#haveNewValue = false; 56 | value = this.#value; 57 | queueUpdateBecausePreviousUsagesMightBeStale(); 58 | } else { 59 | if (value !== this.#value || 60 | prompt !== this.#prompt || 61 | min !== this.#min || 62 | max !== this.#max || 63 | precision !== this.#precision) { 64 | this.#value = clamp(value, min, max); 65 | this.#prompt = prompt; 66 | this.#min = min; 67 | this.#max = max; 68 | this.#precision = precision; 69 | this._update(); 70 | } 71 | } 72 | return value; 73 | } 74 | } 75 | 76 | export function valueDrag(prompt: string, value: number, min = 0, max = 1, precision: number = 2): number { 77 | const node = context.getExistingNodeOrRemove(ValueDragNode, value, min, max); 78 | return node.update(prompt, value, min, max); 79 | } 80 | 81 | -------------------------------------------------------------------------------- /src/text.ts: -------------------------------------------------------------------------------- 1 | import {e} from './utils.js'; 2 | import {Node, context} from './core.js' 3 | 4 | class TextNode extends Node { 5 | #text: string; 6 | 7 | constructor(str: string) { 8 | super('div'); 9 | } 10 | update(str: string) { 11 | if (this.#text !== str) { 12 | this.#text = str; 13 | this.elem.textContent = str; 14 | } 15 | } 16 | } 17 | 18 | class ClassTextNode extends Node { 19 | #text: string; 20 | 21 | constructor(type: string) { 22 | // FIX! You can't pass the type OR we need to fix the code 23 | // that gets an old node because it checks by JS class instanceof 24 | // not by element type 25 | super(type); 26 | } 27 | update(className: string, str: string) { 28 | if (this.#text !== str) { 29 | this.#text = str; 30 | this.elem.textContent = str; 31 | } 32 | this.setClassName(className); 33 | } 34 | } 35 | 36 | class TypeTextNode extends Node { 37 | #text: string; 38 | 39 | constructor(type: string) { 40 | // FIX! You can't pass the type OR we need to fix the code 41 | // that gets an old node because it checks by JS class instanceof 42 | // not by element type 43 | super(type); 44 | } 45 | update(str: string) { 46 | if (this.#text !== str) { 47 | this.#text = str; 48 | this.elem.textContent = str; 49 | } 50 | } 51 | } 52 | 53 | class ColorTextNode extends Node { 54 | #text: string; 55 | #color: string 56 | 57 | constructor(color: string, str: string) { 58 | super('div'); 59 | } 60 | update(color: string, str: string) { 61 | if (this.#text !== str) { 62 | this.#text = str; 63 | this.elem.textContent = str; 64 | } 65 | if (this.#color !== color) { 66 | this.#color = color; 67 | this.elem.style.color = color; 68 | } 69 | } 70 | } 71 | 72 | export function classTypeText(type: string, className: string, str: string) { 73 | const node = context.getExistingNodeOrRemove(ClassTextNode, type, className, str); 74 | node.update(className, str); 75 | } 76 | 77 | export function typeText(type: string, str: string) { 78 | const node = context.getExistingNodeOrRemove(TypeTextNode, type, str); 79 | node.update(str); 80 | } 81 | 82 | export function classText(className: string, str: string) { 83 | classTypeText('div', className, str); 84 | } 85 | 86 | export function text(str: string) { 87 | const node = context.getExistingNodeOrRemove(TextNode, str); 88 | node.update(str); 89 | } 90 | 91 | export function textColored(color: string, str: string) { 92 | const node = context.getExistingNodeOrRemove(ColorTextNode, color, str); 93 | node.update(color, str); 94 | } -------------------------------------------------------------------------------- /DEVNOTES.md: -------------------------------------------------------------------------------- 1 | # Development Notes 2 | 3 | ## How it works ATM 4 | 5 | There is a hierarchy of `Node` (an internal type). 6 | While rendering, we get existing nodes in the order they 7 | were rendering last time. We compare the node we got to 8 | the type of node we want. In other words, if we have top level 9 | code like 10 | 11 | ```js 12 | function renderUI() { 13 | text('Editor'); 14 | speed = sliderFloat('speed', speed); 15 | title = inputText('title', title); 16 | } 17 | ``` 18 | 19 | Then 3 `Node`s are generated, a `TextNode`, a `SliderFloatNode`, 20 | and a `InputTextNode` in that order 21 | 22 | The next time through when we see `text('Editor')` we'll get 23 | the first `Node` and check if it's a `TextNode`. If it is we'll 24 | use it. It should function for any text and any things that need 25 | to updated, should be updated in its `update` method. 26 | 27 | If the node is node `TextNode` then it's discarded. 28 | 29 | This probably means if you change a node near the top of a list 30 | in the hierarchy then everything after it is going to be discarded 31 | and regenerated. 32 | 33 | ATM that's fine just to get things working I think. In the future 34 | we could consider a cache of unused nodes by type and/or some other 35 | ways of optimizing churn. 36 | 37 | ## We need a way to know if a node can be reused 38 | 39 | Guidelines 40 | 41 | * If you allow the user to set `className` then you're update function 42 | must set the className with `this.setClassName(className)` 43 | 44 | * Every part of an element you let a user change you have to check 45 | in `update` if it it's changed 46 | 47 | * If you allow the user to pass the element type then we need a way 48 | to pass that type all the way into `getExistingNodeOrRemove` 49 | so that it can check if an existing node of type X is covering 50 | an element if the required element type 51 | 52 | One idea. We could generate types by element type? Unfortunately 53 | that would not be typescript friendly? 54 | 55 | ```js 56 | class TypeTextNode extends Node { 57 | #text: string; 58 | 59 | constructor(type: string) { 60 | // FIX! You can't pass the type OR we need to fix the code 61 | // that gets an old node because it checks by JS class instanceof 62 | // not by element type 63 | super(type); 64 | } 65 | update(str: string) { 66 | if (this.#text !== str) { 67 | this.#text = str; 68 | this.elem.textContent = str; 69 | } 70 | } 71 | } 72 | 73 | const elementTypeToConstructor = new Map(); 74 | 75 | /** 76 | * Makes one class inherit from another. 77 | * @param {!Object} subClass Class that wants to inherit. 78 | * @param {!Object} superClass Class to inherit from. 79 | */ 80 | function inherit(subClass, superClass) { 81 | /** 82 | * TmpClass. 83 | * @ignore 84 | * @constructor 85 | */ 86 | const TmpClass = function() { }; 87 | TmpClass.prototype = superClass.prototype; 88 | subClass.prototype = new TmpClass(); 89 | }; 90 | 91 | export function typeText(type: string, str: string) { 92 | let ctor = elementTypeToConstructor(type); 93 | if (!ctor) { 94 | ctor = function() { 95 | TextTypeNode.call(type); 96 | } 97 | inherit(ctor, TextTypeNode); 98 | elementTypeToConstructor.set(type, ctor) 99 | } 100 | 101 | // this is the iffy part VVVVV 102 | const node = context.getExistingNodeOrRemove(ctor, type, str); 103 | node.update(str); 104 | } 105 | ``` 106 | 107 | ## Decide how to handle stateful stuff 108 | 109 | Example: A window has the state of position and size. 110 | We really need to expose that to the use. Suggestion 111 | 1 is by ID. (I think ImGUI does it that way). Suggestion 112 | 2 is have the user provide it. 113 | 114 | Advantage to ID is it's opaque. What's saved is not up to the 115 | user. Advantage to exposing it is user can set and save it. 116 | 117 | For now going to try exposing -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImHUI (**I**mmediate **M**ode **H**TML **U**ser **I**nterface) 2 | 3 | [Live Demo](https://greggman.github.io/ImHUI) 4 | 5 | **WAT?** I'm a fan (and a sponsor) of [Dear ImGUI](https://github.com/ocornut/imgui). I've written a couple of articles on it including [this one](https://games.greggman.com/game/imgui-future/) and [this one](https://games.greggman.com/game/rethinking-ui-apis/) 6 | 7 | Lately I thought, I wonder what it would be like to try to make an 8 | HTML library that followed a similar style of API. 9 | 10 | NOTE: This is not Dear ImGUI running in JavaScript. For that see 11 | [this repo](https://github.com/flyover/imgui-js). The difference 12 | is most ImGUI libraries render their own graphics. More specifically 13 | they generate arrays of vertex positions, texture coordinates, and 14 | vertex colors for the glyphs and other lines and rectangles for your 15 | UI. You draw each array of vertices using whatever method you feel like. 16 | 17 | This repo is instead actually using HTML elements like `
` 18 | ``, ``, `