diff --git a/README.md b/README.md index 5250d34..6415a00 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ - [x] AssemblyScript - [x] WebAudio - [x] Wasm AudioWorklet -- [ ] WebGL +- [x] WebGL - [x] WebRTC - [x] QRCode - [ ] Maps @@ -92,7 +92,7 @@ - [x] OAuthRegister - [x] QrCode - [x] WebSockets - - [ ] UI Showcase + - [x] UI Showcase - [x] Components - [x] Header - [x] Toast @@ -104,6 +104,7 @@ - [x] ResetPassword - [x] VerifyEmail - [x] UI + - [x] Button - [x] Fieldset - [x] Input - [x] Label diff --git a/admin/index.html b/admin/index.html index 6e1ca60..478ee11 100644 --- a/admin/index.html +++ b/admin/index.html @@ -1,14 +1,18 @@ - + - - - - + + + + Vasi - Admin +
diff --git a/api/core/middleware.ts b/api/core/middleware.ts index 02c2c29..0be0752 100644 --- a/api/core/middleware.ts +++ b/api/core/middleware.ts @@ -6,6 +6,7 @@ import { kv } from "~/api/core/app.ts" import type { Handler } from '~/api/core/router.ts' import { sessions } from "~/api/core/sessions.ts" import { env } from '~/api/env.ts' +import { IS_DEV } from '~/api/core/constants.ts' const DEBUG = false const ORIGIN_REGEX = /(https:\/\/[^\/\n]+\.deno\.dev)/g @@ -17,6 +18,11 @@ export const cors: Handler = ctx => { ctx.request.headers.get('referer') if (!ctx.request.headers.get('upgrade') && origin) { + if (IS_DEV) { + res.headers.set('access-control-allow-origin', origin) + return + } + const [match] = origin.match(ORIGIN_REGEX) ?? [] if (match) { res.headers.set('access-control-allow-origin', match) diff --git a/as/assembly/gfx/draw.ts b/as/assembly/gfx/draw.ts new file mode 100644 index 0000000..e0be920 --- /dev/null +++ b/as/assembly/gfx/draw.ts @@ -0,0 +1,655 @@ +import { logf, logf2, logf3, logf4, logf6, logi } from '../env' +import { Sketch } from './sketch' +import { Box, Line, Matrix, Notes, Shape, ShapeOpts, WAVE_MIPMAPS, Wave, Note, Params, ParamValue } from './sketch-shared' +import { lineIntersectsRect } from './util' + +const MAX_ZOOM: f32 = 0.5 +const BASE_SAMPLES: f32 = 48000 +const NUM_SAMPLES: f32 = BASE_SAMPLES / MAX_ZOOM +const WAVE_MIPMAPS_THRESHOLD = 3000 +const BLACK_KEYS = [1, 3, 6, 8, 10] + +const enum WaveMode { + Scaled, + Normal, +} + +export function draw( + sketch$: usize, + ptrs$: usize, + matrix$: usize, + width: f32, + height: f32, +): void { + const sketch = changetype(sketch$) + + const m = changetype(matrix$) + const ma: f64 = m.a + const md: f64 = m.d + const me: f64 = m.e + const mf: f64 = m.f + + // shapes + let box: Box + let line: Line + let wave: Wave + let notes: Notes + let params: Params + + const x_gap: f32 = ma > 5 ? 1 : 0 + + let px: f32 = 0 + let py: f32 = 0 + + let ptrs = changetype>(ptrs$) + let ptr: usize + let i: i32 = 0 + while (unchecked(ptr = ptrs[i++])) { + const opts = i32(changetype(ptr).opts) + + switch (opts & 0b1111_1111) { + // + // Box + // + case ShapeOpts.Box: { + box = changetype(ptr) + + const x = f32(box.x * ma + me) + const y = f32(box.y * md + mf) + const w = f32(box.w * ma - x_gap) + let h = f32(box.h * md) + if ( + !(opts & ShapeOpts.NoMargin) + && ( + (!(opts & ShapeOpts.Collapse) || h > 1.5) + && h > 1.5 + ) + ) { + h -= h > 3 ? 1.0 : h > 1.5 ? .5 : 0 + } + + // check if visible + if (x > width + || y > height + || x + w < 0 + || y + h < 0 + ) continue + + if (opts & ShapeOpts.Cols) { + const SNAPS: f32 = 16 + const cols_n = i32(SNAPS - 1) + for (let col = 0; col < cols_n; col++) { + const col_x = f32(f32((box.w / SNAPS) * f32(col + 1) + box.x) * ma + me) + const lw = f32(col % 16 === 15 ? 1.5 : col % 4 === 3 ? 1 : 0.5) + sketch.drawLine( + col_x, y, + col_x, y + h, + box.color, box.alpha, lw + ) + } + + } + else { + sketch.drawBox( + x, y, w, h, + box.color, box.alpha + ) + } + + continue + } + + // + // Notes + // + case ShapeOpts.Notes: { + notes = changetype(ptr) + + const x = f32(notes.x * ma + me) + const y = f32(notes.y * md + mf) + const w = f32(notes.w * ma - x_gap) + let h = f32(notes.h * md) + if ( + !(opts & ShapeOpts.NoMargin) + && ( + (!(opts & ShapeOpts.Collapse) || h > 1.5) + && h > 1.5 + ) + ) { + h -= h > 3 ? 1.0 : h > 1.5 ? .5 : 0 + } + + // check if visible + if (x > width + || y > height + || x + w < 0 + || y + h < 0 + ) continue + + const notesPtrs = changetype>(usize(notes.notes$)) + const isFocused = notes.isFocused + const hoveringNote$ = usize(notes.hoveringNote$) + let note: Note + let note$: usize + const min = notes.min + const SCALE_X: f32 = 1.0 / 16.0 + const scale_N = notes.max - min + + if (isFocused) { + for (let n = 0; f32(n) <= scale_N; n++) { + const note = scale_N - (f32(n)) + min + const note_key = note % 12 + const isBlack = BLACK_KEYS.includes(i32(note_key)) + if (!isBlack) continue + + let nh = h / (scale_N + 1) + const ny = h - nh * ((f32(note) + 1) - min) // y + nh -= nh > 3 ? 1.0 : nh > 1.5 ? .5 : 0 + + sketch.drawBox( + x, + y + f32(ny) + 1.0, + f32(w - x_gap), + f32(nh), + 0x0, notes.alpha * 0.2 + ) + } + } + + if (isFocused) { + // draw shadows + + let i: i32 = 0 + while (note$ = unchecked(notesPtrs[i++])) { + note = changetype(note$) + + const n = note.n + const time = note.time + const length = note.length + const vel = note.vel + + const alpha: f32 = isFocused + ? hoveringNote$ === note$ + ? 1 + : .45 + (.55 * vel) + : .2 + (.8 * vel) + + const color: f32 = 0 + + const nx = (time * SCALE_X) * ma + if (nx >= w) continue + + let nh = h / (scale_N + 1) + const ny = h - nh * ((n + 1) - min) // y + let nw = (length * SCALE_X) * ma + if (nx + nw > w) { + nw = w - nx + } + nh -= nh > 3 ? 1.0 : nh > 1.5 ? .5 : 0 + nh += f32(.008 * md) + sketch.drawBox( + x + f32(nx), + y + f32(ny) + 1.0, + f32(nw - x_gap), + f32(nh), + color, notes.alpha * alpha + ) + } + } + + let i: i32 = 0 + while (note$ = unchecked(notesPtrs[i++])) { + note = changetype(note$) + + const n = note.n + const time = note.time + const length = note.length + const vel = note.vel + + const alpha: f32 = isFocused + ? hoveringNote$ === note$ + ? 1 + : .45 + (.55 * vel) + : .2 + (.8 * vel) + + const color = hoveringNote$ === note$ + ? notes.hoverColor + : notes.color + + const nx = (time * SCALE_X) * ma + if (nx >= w) continue + + let nh = h / (scale_N + 1) + const ny = h - nh * ((n + 1) - min) // y + let nw = (length * SCALE_X) * ma + if (nx + nw > w) { + nw = w - nx + } + nh -= nh > 3 ? 1.0 : nh > 1.5 ? .5 : 0 + sketch.drawBox( + x + f32(nx), + y + f32(ny) + 1.0, + f32(nw - x_gap), + f32(nh), + color, notes.alpha * alpha + ) + } + + continue + } + + // + // Line + // + case ShapeOpts.Line: { + line = changetype(ptr) + + const hw = line.lw / 2.0 + const x0 = f32(line.x0 * ma + me - hw) + let y0 = f32(line.y0 * md + mf - hw) + const x1 = f32(line.x1 * ma + me - hw) + let y1 = f32(line.y1 * md + mf - hw) + + if (opts & ShapeOpts.InfY) { + y0 = 0 + y1 = height + } + + // check if visible + if (!lineIntersectsRect(x0, y0, x1, y1, 0, 0, width, height)) continue + + sketch.drawLine( + x0, y0, + x1, y1, + line.color, line.alpha, + line.lw, + ) + + continue + } + + // + // Params + // + case ShapeOpts.Params: { + params = changetype(ptr) + + const x = f32(params.x * ma + me) + const y = f32(params.y * md + mf) + const w = f32(params.w * ma - x_gap) + const h = f32(params.h * md - 1) + + // check if visible + if (x > width + || y > height + || x + w < 0 + || y + h < 0 + ) continue + + const paramsPtrs = changetype>(usize(params.params$)) + let paramValue: ParamValue + let paramValue$: usize + + let x0: f32 = 0 + let y0: f32 = 0 + let x1: f32 = 0 + let y1: f32 = 0 + + let lastAmt: f32 = 0 + + const hh: f32 = h / 2.0 + const hhs: f32 = hh * 0.65 + + const HAS_SHADOW = opts & ShapeOpts.Shadow + + paramValue$ = unchecked(paramsPtrs[0]) + if (paramValue$) { + paramValue = changetype(paramValue$) + + const time = paramValue.time + const length = paramValue.length + const slope = paramValue.slope + const amt = paramValue.amt + + x0 = 0 + x1 = x + f32(time * ma) + y1 = y + amt * hhs + hhs + y0 = y1 + + if (HAS_SHADOW) sketch.drawLine( + x0 + 1, y0 + 1, + x1 + 1, y1 + 1, + 0x0, 1.0, + 1.0 + ) + + sketch.drawLine( + x0, y0, + x1, y1, + params.color, params.alpha, + 1.0 + ) + + lastAmt = amt + } + + let i: i32 = 0 + while (paramValue$ = unchecked(paramsPtrs[i++])) { + paramValue = changetype(paramValue$) + + const time = paramValue.time + const length = paramValue.length + const slope = paramValue.slope + const amt = paramValue.amt + + x0 = x + f32(time * ma) + y0 = y + lastAmt * hhs + hhs + x1 = x + f32((time + length) * ma) + y1 = y + amt * hhs + hhs + + if (HAS_SHADOW) sketch.drawLine( + x0 + 1, y0 + 1, + x1 + 1, y1 + 1, + 0x0, 1.0, + 1.0 + ) + sketch.drawLine( + x0, y0, + x1, y1, + params.color, params.alpha, + 1.0 + ) + + lastAmt = amt + } + + if (HAS_SHADOW) sketch.drawLine( + x1 + 1, y1 + 1, + width + 1, y1 + 1, + 0x0, 1.0, + 1.0 + ) + sketch.drawLine( + x1, y1, + width, y1, + params.color, params.alpha, + 1.0 + ) + + continue + } + + // + // Wave + // + // case ShapeOpts.Wave: { + // wave = changetype(ptr) + + // const x = f32(wave.x * ma + me) + // const y = f32(wave.y * md + mf) + // const w = f32(wave.w * ma - x_gap) + // const h = f32(wave.h * md - 1) + + // // check if visible + // if (x > width + // || y > height + // || x + w < 0 + // || y + h < 0 + // ) continue + + // // + // // sample coeff for zoom level + // // + // let sample_coeff: f64 = f64((NUM_SAMPLES / wave.coeff / 2.0) / ma) + + // // + // // setup wave pointers + // // + // let p = i32(wave.floats$) + // let p_index: i32 = 0 + // let n: f64 = 0 + // let n_len = f64(wave.len) + + // // + // // determine right edge + // // + // let right = x + w + + // // + // // determine left edge (cx) + // // + // let cx: f64 = f64(x) + // let ox: f32 = 0 + // // if left edge is offscreen + // if (cx < 0) { + // ox = -x + // cx = 0 + // } + + // // + // // determine width (cw) + // // + // let cw: f32 = w + // let ow: f32 = 0 + // // if right edge if offscreen + // if (right > width) { + // // logf(444) + // ow = right - width + // cw = f32(width - cx) + // right = width + // } + // // or if left edge is offscreen + // else if (x < 0) { + // cw -= ox + // } + + // let x_step: f64 = f64(ma / NUM_SAMPLES) + // let n_step: f64 = 1.0 + // let mul: f64 = 1.0 + // let lw: f32 = 1.1 + + // let waveMode: WaveMode = WaveMode.Scaled + + // for (let i = 0; i < WAVE_MIPMAPS; i++) { + // const threshold = WAVE_MIPMAPS_THRESHOLD / (2 ** i) + // if (ma < threshold) { + // waveMode = WaveMode.Normal + // p_index += i32(Math.floor(n_len)) + // n_len = Math.floor(n_len / 2.0) + // mul *= 2.0 + // x_step *= 2.0 + // lw = 1.1 - (0.8 * (1 - + // (f32(ma / WAVE_MIPMAPS_THRESHOLD) ** .35) + // )) + // } + // else { + // break + // } + // } + + // n_step = sample_coeff / (mul / x_step) + + // p += p_index << 2 + + // const hh: f32 = h / 2 + // const yh = y + hh + + // // advance the pointer if left edge is offscreen + // if (ox) { + // n += Math.floor(ox / x_step) + // } + // n = Math.floor(n) + + // let nx = (n * n_step) % n_len + + // switch (waveMode) { + // case WaveMode.Scaled: { + // let nfrac = nx - Math.floor(nx) + + // let x0 = f32(cx) + // let y0 = yh + readSampleLerp(p, f32(nx), nfrac) * hh + + // if (opts & ShapeOpts.Join) { + // sketch.drawLine( + // px, py, + // x0, y0, + // wave.color, wave.alpha, + // lw + // ) + // } + + // do { + // cx += x_step + // nx += n_step + // if (nx >= n_len) nx -= n_len + + // nfrac = nx - Math.floor(nx) + + // const x1 = f32(cx) + // const y1 = yh + readSampleLerp(p, f32(nx), nfrac) * hh + + // sketch.drawLine( + // x0, y0, + // x1, y1, + // wave.color, wave.alpha, + // lw + // ) + + // x0 = x1 + // y0 = y1 + // } while (cx < right) + + // px = x0 + // py = y0 + + // break + // } + + // case WaveMode.Normal: { + // let s = f32.load(p + (i32(nx) << 2)) + + // // move to v0 + // let x0 = f32(cx) + // let y0 = yh + s * hh + + // if (opts & ShapeOpts.Join) { + // sketch.drawLine( + // px, py, + // x0, y0, + // wave.color, wave.alpha, + // lw + // ) + // } + + // do { + // cx += x_step + // nx += n_step + // if (nx >= n_len) nx -= n_len + + // const x1 = f32(cx) + // const y1 = yh + f32.load(p + (i32(nx) << 2)) * hh + + // sketch.drawLine( + // x0, y0, + // x1, y1, + // wave.color, wave.alpha, + // lw + // ) + + // x0 = x1 + // y0 = y1 + // } while (cx < right) + + // px = x0 + // py = y0 + + // break + // } + // } + + // continue + // } + + case ShapeOpts.Wave: { + wave = changetype(ptr) + + const x = f32(wave.x * ma + me) + const y = f32(wave.y * md + mf) + const w = f32(wave.w * ma - x_gap) + const h = f32(wave.h * md - 1) + + // check if visible + if (x > width + || y > height + || x + w < 0 + || y + h < 0 + ) continue + + let nx: f32 = 0 + let p = i32(wave.floats$) + + let x_step: f32 = .5 + let s: f32 = f32.load(p + (i32(nx) << 2)) + let n_step = wave.coeff / (1.0 / x_step) + + let cx = x + let right = x + w + + let x0 = cx + let y0 = y + h * (s * 0.5 + 0.5) + + let lw = wave.lw + + let x1: f32 + let y1: f32 + + do { + cx += x_step + nx += n_step + if (cx >= right) break + + s = f32.load(p + (i32(nx) << 2)) + + x1 = cx + y1 = y + h * (s * 0.5 + 0.5) + + sketch.drawLine( + x0, y0, + x1, y1, + wave.color, wave.alpha, + lw + ) + + x0 = x1 + y0 = y1 + } while (cx < right) + + continue + } + + // default: { + // logf(66666) + // } + + } // end switch + } // end for +} + +export function flushSketch(sketch$: usize): void { + const sketch = changetype(sketch$) + if (sketch.ptr) { + sketch.flush() + } +} + +// +// helpers +// + +// @ts-ignore +@inline +function readSampleLerp(p: i32, nx: f32, frac: f64): f32 { + const s0 = f32.load(p + (i32(nx) << 2)) + const s1 = f32.load(p + (i32(nx + 1) << 2)) + return f32(f64(s0) + f64(s1 - s0) * frac) +} diff --git a/as/assembly/gfx/env.ts b/as/assembly/gfx/env.ts new file mode 100644 index 0000000..f2f2fee --- /dev/null +++ b/as/assembly/gfx/env.ts @@ -0,0 +1,3 @@ +// @ts-ignore +@external('env', 'flushSketch') +export declare function flushSketch(count: i32): void diff --git a/as/assembly/gfx/index.ts b/as/assembly/gfx/index.ts new file mode 100644 index 0000000..a3dad99 --- /dev/null +++ b/as/assembly/gfx/index.ts @@ -0,0 +1,47 @@ +import { Sketch } from './sketch' +import { Box, Line, Matrix, Note, Notes, ParamValue, Params, Wave } from './sketch-shared' + +export * from '../alloc' +export * from './draw' + +export function createSketch( + a_vert$: usize, + a_style$: usize, +): Sketch { + return new Sketch( + a_vert$, + a_style$, + ) +} + +export function createBox(): usize { + return changetype(new Box()) +} + +export function createLine(): usize { + return changetype(new Line()) +} + +export function createWave(): usize { + return changetype(new Wave()) +} + +export function createNotes(): usize { + return changetype(new Notes()) +} + +export function createNote(): usize { + return changetype(new Note()) +} + +export function createParams(): usize { + return changetype(new Params()) +} + +export function createParamValue(): usize { + return changetype(new ParamValue()) +} + +export function createMatrix(): usize { + return changetype(new Matrix()) +} diff --git a/as/assembly/gfx/sketch-shared.ts b/as/assembly/gfx/sketch-shared.ts new file mode 100644 index 0000000..0e9d384 --- /dev/null +++ b/as/assembly/gfx/sketch-shared.ts @@ -0,0 +1,142 @@ +const PAGE_BYTES = 1024 +export const MAX_BYTES = PAGE_BYTES * 32 +export const MAX_GL_INSTANCES = MAX_BYTES >> 2 +export const WAVE_MIPMAPS = 13 + +export enum VertOpts { + Box /* */ = 0b001, + Line /**/ = 0b010, +} + +export enum ShapeOpts { + // kind + Box /* */ = 0b0000_0000_0000_0001, + Line /* */ = 0b0000_0000_0000_0010, + Wave /* */ = 0b0000_0000_0000_0100, + Notes /* */ = 0b0000_0000_0000_1000, + Params /* */ = 0b0000_0000_0001_0000, + // flags + Collapse /* */ = 0b0000_0001_0000_0000, + NoMargin /* */ = 0b0000_0010_0000_0000, + Join /* */ = 0b0000_0100_0000_0000, + Cols /* */ = 0b0000_1000_0000_0000, + InfY /* */ = 0b0001_0000_0000_0000, + Shadow /* */ = 0b0010_0000_0000_0000, +} + +@unmanaged +export class Shape { + opts: f32 = f32(ShapeOpts.Box) +} + +@unmanaged +export class Box { + opts: f32 = f32(ShapeOpts.Box) + + x: f32 = 0 + y: f32 = 0 + w: f32 = 0 + h: f32 = 0 + + color: f32 = 0x0 + alpha: f32 = 1.0 +} + +@unmanaged +export class Notes { + opts: f32 = f32(ShapeOpts.Notes) + + x: f32 = 0 + y: f32 = 0 + w: f32 = 0 + h: f32 = 0 + + color: f32 = 0x0 + alpha: f32 = 1.0 + + isFocused: f32 = 0 + notes$: f32 = 0 + hoveringNote$: f32 = 0 + hoverColor: f32 = 0 + + min: f32 = 0 + max: f32 = 0 +} + +@unmanaged +export class Note { + n: f32 = 0 + time: f32 = 0 + length: f32 = 0 + vel: f32 = 0 +} + +@unmanaged +export class Params { + opts: f32 = f32(ShapeOpts.Params) + + x: f32 = 0 + y: f32 = 0 + w: f32 = 0 + h: f32 = 0 + + color: f32 = 0x0 + alpha: f32 = 1.0 + + params$: f32 = 0 + hoveringParam$: f32 = 0 + hoverColor: f32 = 0 +} + +@unmanaged +export class ParamValue { + time: f32 = 0 + length: f32 = 0 + slope: f32 = 0 + amt: f32 = 0 +} + +@unmanaged +export class Line { + opts: f32 = f32(ShapeOpts.Line) + + x0: f32 = 0 + y0: f32 = 0 + + x1: f32 = 0 + y1: f32 = 0 + + color: f32 = 0x0 + alpha: f32 = 1.0 + lw: f32 = 1 +} + +@unmanaged +export class Wave { + opts: f32 = f32(ShapeOpts.Wave) + + x: f32 = 0 + y: f32 = 0 + w: f32 = 0 + h: f32 = 0 + + color: f32 = 0x0 + alpha: f32 = 1.0 + lw: f32 = 1 + + floats$: f32 = 0 + len: f32 = 0 + offset: f32 = 0 + coeff: f32 = 1 +} + +@unmanaged +export class Matrix { + constructor() { } + a: f64 = 0 + b: f64 = 0 + c: f64 = 0 + d: f64 = 0 + e: f64 = 0 + f: f64 = 0 +} diff --git a/as/assembly/gfx/sketch.ts b/as/assembly/gfx/sketch.ts new file mode 100644 index 0000000..81bc48b --- /dev/null +++ b/as/assembly/gfx/sketch.ts @@ -0,0 +1,71 @@ +import { Floats } from '../types' +import { flushSketch } from './env' +import { MAX_GL_INSTANCES, VertOpts } from './sketch-shared' + +/** + * Sketch holds the data that is sent to WebGL. + */ +export class Sketch { + ptr: u32 = 0 + a_vert: Floats + a_style: Floats + constructor( + public a_vert$: usize, + public a_style$: usize, + ) { + this.a_vert = changetype(a_vert$) + this.a_style = changetype(a_style$) + } + @inline + flush(): void { + flushSketch(this.ptr) + this.ptr = 0 + } + @inline + advance(): void { + if (++this.ptr === MAX_GL_INSTANCES) { + this.flush() + } + } + @inline + drawBox( + x: f32, y: f32, w: f32, h: f32, + color: f32, + alpha: f32, + ): void { + const ptr = this.ptr + const ptr4 = (ptr * 4) << 2 + store4(this.a_vert$ + ptr4, x, y, w, h) + store4(this.a_style$ + ptr4, color, alpha, f32(VertOpts.Box), 1.0) + this.advance() + } + @inline + drawLine( + x0: f32, y0: f32, + x1: f32, y1: f32, + color: f32, + alpha: f32, + lineWidth: f32 + ): void { + const ptr = this.ptr + const ptr4 = (ptr * 4) << 2 + store4(this.a_vert$ + ptr4, x0, y0, x1, y1) + store4(this.a_style$ + ptr4, color, alpha, f32(VertOpts.Line), lineWidth) + this.advance() + } +} + +// @ts-ignore +@inline +function store4(ptr: usize, x: f32, y: f32, z: f32, w: f32): void { + const v = f32x4(x, y, z, w) + v128.store(ptr, v) +} + +// @ts-ignore +@inline +function store2(ptr: usize, x: f32, y: f32): void { + f32.store(ptr, x) + f32.store(ptr, y, 4) +} + diff --git a/as/assembly/gfx/util.ts b/as/assembly/gfx/util.ts new file mode 100644 index 0000000..a0f659b --- /dev/null +++ b/as/assembly/gfx/util.ts @@ -0,0 +1,16 @@ + +export function lineIntersectsRect( + x0: f32, y0: f32, x1: f32, y1: f32, + rectX: f32, rectY: f32, rectWidth: f32, rectHeight: f32 +): boolean { + const minX: f32 = Mathf.min(x0, x1) + const minY: f32 = Mathf.min(y0, y1) + const maxX: f32 = Mathf.max(x0, x1) + const maxY: f32 = Mathf.max(y0, y1) + + if (maxX < rectX || minX > rectX + rectWidth || maxY < rectY || minY > rectY + rectHeight) { + return false + } + + return true +} diff --git a/as/assembly/types.ts b/as/assembly/types.ts new file mode 100644 index 0000000..edb9a2b --- /dev/null +++ b/as/assembly/types.ts @@ -0,0 +1 @@ +export type Floats = StaticArray diff --git a/asconfig-gfx.json b/asconfig-gfx.json new file mode 100644 index 0000000..71574bf --- /dev/null +++ b/asconfig-gfx.json @@ -0,0 +1,35 @@ +{ + "targets": { + "debug": { + "outFile": "./as/build/gfx.wasm", + "textFile": "./as/build/gfx.wat", + "sourceMap": true, + "debug": true, + "noAssert": true + }, + "release": { + "outFile": "./as/build/gfx.wasm", + "textFile": "./as/build/gfx.wat", + "sourceMap": true, + "debug": false, + "optimizeLevel": 0, + "shrinkLevel": 0, + "converge": false, + "noAssert": true + } + }, + "options": { + "enable": [ + "simd", + "relaxed-simd", + "threads" + ], + "sharedMemory": true, + "importMemory": false, + "initialMemory": 1000, + "maximumMemory": 1000, + "bindings": "raw", + "runtime": "incremental", + "exportRuntime": true + } +} diff --git a/index.html b/index.html index 410565a..64b9c9e 100644 --- a/index.html +++ b/index.html @@ -1,20 +1,31 @@ - + - + - - - + + + - - - + + + Vasi + +
diff --git a/lib/caching-router.ts b/lib/caching-router.ts index f3e759e..33067e1 100644 --- a/lib/caching-router.ts +++ b/lib/caching-router.ts @@ -1,7 +1,7 @@ import { dispose } from 'sigui' export function CachingRouter(routes: Record JSX.Element>) { - const cache = new Map() + const cache = new Map() let shouldDispose = false return function (pathname: string) { if (shouldDispose) { @@ -15,6 +15,7 @@ export function CachingRouter(routes: Record JSX.Element>) { if (!(pathname in routes)) return let el = cache.get(pathname) if (!el) cache.set(pathname, el = routes[pathname]()) + else requestAnimationFrame(() => el!.focus?.()) return el } } diff --git a/lib/watcher.ts b/lib/watcher.ts index be40fcd..9ada7c3 100644 --- a/lib/watcher.ts +++ b/lib/watcher.ts @@ -1,4 +1,7 @@ if (import.meta.env.DEV) { - const es = new EventSource(import.meta.env.VITE_API_URL + '/watcher') + const url = location.origin.includes('devito') + ? import.meta.env.VITE_API_URL + '/watcher' + : Object.assign(new URL(location.origin), { port: 8000 }).href + 'watcher' + const es = new EventSource(url) es.onopen = () => es.onopen = () => (location.href = location.href) } diff --git a/package.json b/package.json index 49bffa1..a1ff49b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ "kysely-ctl": "^0.9.0", "open-in-editor": "^2.2.0", "postcss": "^8.4.47", + "qrcode-terminal": "^0.12.0", "tailwindcss": "^3.4.13", + "tailwindcss-image-rendering": "^1.0.2", "utils": "github:stagas/utils", "visitor-as": "^0.11.4", "vite": "^5.4.8", @@ -61,6 +63,7 @@ "@types/pg": "^8.11.10", "dotenv": "^16.4.5", "easygenqr": "^1.3.0", + "gl-util": "github:stagas/gl-util", "lucide": "^0.446.0", "pg": "^8.13.0", "sigui": "github:stagas/sigui", diff --git a/src/as/gfx/anim.ts b/src/as/gfx/anim.ts new file mode 100644 index 0000000..63dc2de --- /dev/null +++ b/src/as/gfx/anim.ts @@ -0,0 +1,87 @@ +import { Sigui } from 'sigui' +import { state } from '~/src/state.ts' + +const DEBUG = false //true + +export enum AnimMode { + Auto = 'auto', + On = 'on', + Off = 'off', +} + +const Modes = Object.values(AnimMode) + +export type Anim = ReturnType + +export function Anim() { + DEBUG && console.log('[anim] create') + using $ = Sigui() + + const info = $({ + isRunning: false, + mode: state.$.animMode, + epoch: 0, + }) + + const ticks = new Set<() => boolean | void>() + + let lastEpoch = -1 // cause initial draw to happen + let animFrame: any + + const tick = $.fn(function tick() { + DEBUG && console.log('[anim] tick', info.epoch) + + if (info.epoch === lastEpoch && info.mode === AnimMode.Auto) { + info.isRunning = false + DEBUG && console.log('[anim] exit') + return + } + + lastEpoch = info.epoch + + for (const tick of ticks) { + if (tick()) info.epoch++ + } + + animFrame = requestAnimationFrame(tick) + }) + + function cycle() { + info.mode = Modes[ + (Modes.indexOf(info.mode) + 1) % Modes.length + ] + } + + function stop() { + cancelAnimationFrame(animFrame) + info.isRunning = false + } + + function start() { + if (info.isRunning) return + stop() + info.isRunning = true + animFrame = requestAnimationFrame(tick) + } + + $.fx(() => { + const { epoch, mode } = info + $() + DEBUG && console.log('[anim]', mode, epoch) + if (mode === AnimMode.Off) { + stop() + } + else { + start() + } + }) + + $.fx(() => () => { + DEBUG && console.log('[anim] dispose') + stop() + }) + + state.animCycle = cycle + + return { info, ticks, cycle } +} diff --git a/src/as/gfx/gfx.ts b/src/as/gfx/gfx.ts new file mode 100644 index 0000000..e88f863 --- /dev/null +++ b/src/as/gfx/gfx.ts @@ -0,0 +1,38 @@ +import { Matrix, Meshes, Rect, Shapes, Sketch } from 'gfx' +import { initGL } from 'gl-util' +import { Sigui } from 'sigui' + +const DEBUG = true + +export function Gfx({ canvas }: { + canvas: HTMLCanvasElement +}) { + using $ = Sigui() + + const GL = initGL(canvas, { + antialias: true, + alpha: true, + preserveDrawingBuffer: true + }) + + function createContext(view: Rect, matrix: Matrix) { + const meshes = Meshes(GL, view) + const sketch = Sketch(GL, view) + meshes.add($, sketch) + + function createShapes() { + return Shapes(view, matrix) + } + + return { meshes, sketch, createShapes } + } + + $.fx(() => () => { + DEBUG && console.debug('[gfx] dispose') + GL.reset() + }) + + return { + createContext + } +} diff --git a/src/as/gfx/glsl.ts b/src/as/gfx/glsl.ts new file mode 100644 index 0000000..bca423e --- /dev/null +++ b/src/as/gfx/glsl.ts @@ -0,0 +1,91 @@ +import { VertOpts } from '~/as/assembly/gfx/sketch-shared.ts' + +const hasBits = (varname: string, ...bits: number[]) => + /*glsl*/`(int(${varname}) & (${bits.join(' | ')})) != 0` + +export const vertex = /*glsl*/` +#version 300 es +precision highp float; + +in float a_quad; +in vec4 a_vert; +in vec4 a_style; + +uniform float u_pr; +uniform vec2 u_screen; + +out float v_opts; +out vec2 v_uv; +out vec2 v_size; +out vec2 v_color; + +vec2 perp(vec2 v) { + return vec2(-v.y, v.x); +} + +void main() { + vec2 a_color = a_style.xy; + float a_opts = a_style.z; + float a_lineWidth = a_style.w; + + vec2 pos = vec2(0.,0.); + + vec2 quad = vec2( + mod(a_quad, 2.0), + floor(a_quad / 2.0) + ); + + if (${hasBits('a_opts', VertOpts.Line)}) { + vec2 a = a_vert.xy; + vec2 b = a_vert.zw; + vec2 v = b - a; + + float mag = length(v); + float mag1 = 1.0 / mag; + vec2 n = perp(v) * mag1; + + float lw = a_lineWidth; + float lwh = lw * 0.5; + + mat3 transform = mat3( + v.x, v.y, 0.0, + n.x * lw, n.y * lw, 0.0, + a.x - n.x * lwh, a.y - n.y * lwh, 1.0 + ); + + pos = (transform * vec3(quad, 1.0)).xy * u_pr; + } + else { + pos = ceil((a_vert.xy + a_vert.zw * quad) * u_pr); + } + + pos /= u_screen * 0.5; + pos -= 1.0; + pos.y *= -1.0; + + gl_Position = vec4(pos, 0.0, 1.0); + + v_color = a_color; +} +` +export const fragment = /*glsl*/` +#version 300 es + +precision highp float; + +in vec2 v_color; +out vec4 fragColor; + +vec3 intToRgb(int color) { + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + return vec3(float(r), float(g), float(b)) / 255.0; +} + +void main() { + vec3 color = intToRgb(int(v_color.x)).rgb; + float alpha = v_color.y; + fragColor = vec4(color, alpha); +} +` diff --git a/src/as/gfx/index.ts b/src/as/gfx/index.ts new file mode 100644 index 0000000..4dc024c --- /dev/null +++ b/src/as/gfx/index.ts @@ -0,0 +1,12 @@ +export * from './anim.ts' +export * from './gfx.ts' +export * from './glsl.ts' +export * from './mesh-info.ts' +export * from './meshes.ts' +export * from './shapes.ts' +export * from './sketch-info.ts' +export * from './sketch.ts' +export * from './types.ts' +export * from './wasm-matrix.ts' +export * from './wasm.ts' + diff --git a/src/as/gfx/mesh-info.ts b/src/as/gfx/mesh-info.ts new file mode 100644 index 0000000..d45fe0d --- /dev/null +++ b/src/as/gfx/mesh-info.ts @@ -0,0 +1,57 @@ +import { GL, GLBuffer, GLBufferTarget } from 'gl-util' +import { Sigui } from 'sigui' + +export interface MeshSetup< + U extends Parameters[0] +> { + vertex: string | ((gl?: WebGL2RenderingContext) => string) + fragment: string | ((gl?: WebGL2RenderingContext) => string) + vao?: U +} + +export type MeshInfo = ReturnType + +export function MeshInfo< + T extends GLBufferTarget, + U extends Parameters[0] +>(GL: GL, setup: MeshSetup) { + using $ = Sigui() + + const { gl } = GL + + const shaders = GL.createShaders(setup) + const program = GL.createProgram(shaders) + + let use = () => GL.useProgram(program) + let useProgram = () => GL.useProgram(program) + + const info = { + program, + use, + useProgram, + vao: undefined as WebGLVertexArrayObject | undefined, + attribs: {} as { [K in keyof U]: GLBuffer> }, + get uniforms() { + GL.useProgram(program) + return GL.uniforms + } + } + + if (setup.vao) { + info.vao = GL.createVertexArray() + info.attribs = GL.addVertexAttribs(setup.vao) as any + info.use = () => GL.use(program, info.vao!) + info.useProgram = () => GL.useProgram(program) + } + + $.fx(() => () => { + GL.deleteShaders(shaders) + gl.deleteProgram(program) + if (info.vao) { + gl.deleteVertexArray(info.vao) + GL.deleteAttribs(info.attribs!) + } + }) + + return info +} diff --git a/src/as/gfx/meshes.ts b/src/as/gfx/meshes.ts new file mode 100644 index 0000000..aafa3b4 --- /dev/null +++ b/src/as/gfx/meshes.ts @@ -0,0 +1,56 @@ +import type { Rect } from 'gfx' +import { GL } from 'gl-util' +import { Sigui } from 'sigui' + +const DEBUG = false + +export interface MeshProps { + GL: GL + view: Rect +} + +export interface Mesh { + draw(): void +} + +export function Meshes(GL: GL, view: Rect) { + DEBUG && console.debug('[meshes] create') + using $ = Sigui() + + const { gl, canvas } = GL + const meshes = new Set() + + function clear() { + const x = view.x_pr + const y = canvas.height - view.h_pr - view.y_pr + const w = Math.max(0, view.w_pr) + const h = Math.max(0, view.h_pr) + gl.viewport(x, y, w, h) + gl.scissor(x, y, w, h) + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) + } + + function draw() { + DEBUG && console.debug('[meshes] draw', meshes.size) + clear() + for (const mesh of meshes) { + mesh.draw() + } + } + + function add($: Sigui, mesh: Mesh) { + $.fx(() => { + meshes.add(mesh) + return () => { + meshes.delete(mesh) + } + }) + } + + $.fx(() => () => { + DEBUG && console.debug('[meshes] clear') + meshes.clear() + }) + + return { GL, draw, add } +} diff --git a/src/as/gfx/shapes.ts b/src/as/gfx/shapes.ts new file mode 100644 index 0000000..d256133 --- /dev/null +++ b/src/as/gfx/shapes.ts @@ -0,0 +1,462 @@ +import { wasm, WasmMatrix, type Matrix, type Rect } from 'gfx' +import { Sigui } from 'sigui' +import { PointLike, Struct } from 'utils' +import { ShapeOpts, type Box, type Line, type Notes, type Params, type Wave } from '~/as/assembly/gfx/sketch-shared.ts' + +export namespace Shape { + // Box + export const Box = Struct({ + opts: 'f32', + + x: 'f32', + y: 'f32', + w: 'f32', + h: 'f32', + + color: 'f32', + alpha: 'f32', + }) + + export type Box = [ + opts: ShapeOpts.Box, + + x: number, + y: number, + w: number, + h: number, + + color: number, + alpha: number, + ] + + // Cols + export const Cols = Struct({ + opts: 'f32', + + x: 'f32', + y: 'f32', + w: 'f32', + h: 'f32', + + color: 'f32', + alpha: 'f32', + }) + + export type Cols = [ + opts: ShapeOpts.Cols, + + x: number, + y: number, + w: number, + h: number, + + color: number, + alpha: number, + ] + + // Notes + export const Notes = Struct({ + opts: 'f32', + + x: 'f32', + y: 'f32', + w: 'f32', + h: 'f32', + + color: 'f32', + alpha: 'f32', + + isFocused: 'f32', + notes$: 'f32', + hoveringNote$: 'f32', + hoverColor: 'f32', + + min: 'f32', + max: 'f32', + }) + + export type Notes = [ + opts: ShapeOpts.Notes, + + x: number, + y: number, + w: number, + h: number, + + color: number, + alpha: number, + + isFocused: number, + notes$: number, + hoveringNote$: number, + hoverColor: number, + + min: number, + max: number, + ] + + // Params + export const Params = Struct({ + opts: 'f32', + + x: 'f32', + y: 'f32', + w: 'f32', + h: 'f32', + + color: 'f32', + alpha: 'f32', + + params$: 'f32', + hoveringParam$: 'f32', + hoverColor: 'f32', + }) + + export type Params = [ + opts: ShapeOpts.Params, + + x: number, + y: number, + w: number, + h: number, + + color: number, + alpha: number, + + params$: number, + hoveringParam$: number, + hoverColor: number, + ] + + // Line + export const Line = Struct({ + opts: 'f32', + + x0: 'f32', + y0: 'f32', + + x1: 'f32', + y1: 'f32', + + color: 'f32', + alpha: 'f32', + lw: 'f32', + }) + + export type Line = [ + opts: ShapeOpts.Line, + + x0: number, + y0: number, + + x1: number, + y1: number, + + color: number, + alpha: number, + lw: number, + ] + + // Wave + export const Wave = Struct({ + opts: 'f32', + + x: 'f32', + y: 'f32', + w: 'f32', + h: 'f32', + + color: 'f32', + alpha: 'f32', + lw: 'f32', + + floats$: 'f32', + len: 'f32', + offset: 'f32', + coeff: 'f32', + }) + + export type Wave = [ + opts: ShapeOpts.Wave, + + x: number, + y: number, + w: number, + h: number, + + color: number, + alpha: number, + lw: number, + + floats$: number, + len: number, + offset: number, + coeff: number, + ] +} + +export type Shapes = ReturnType + +export function Shapes(view: Rect, matrix: Matrix) { + using $ = Sigui() + + type BoxView = ReturnType + type LineView = ReturnType + type WaveView = ReturnType + + type ShapeView = BoxView | LineView | WaveView + + const shapes = new Set() + const mat2d = WasmMatrix(view, matrix) + + const info = $({ + needUpdate: false, + ptrs: wasm.alloc(Uint32Array, 1) + }) + + function clear() { + for (const shape of [...shapes]) { + shape.remove() + } + } + + function update() { + let ptrs = info.ptrs + + const neededSize = shapes.size + 1 // +1 for ending null + + if (ptrs.length !== neededSize) { + ptrs.free() + ptrs = info.ptrs = wasm.alloc(Uint32Array, neededSize) + } + + let i = 0 + for (const s of shapes) { + if (s.visible) { + ptrs[i++] = s.view.ptr + } + } + ptrs[i++] = 0 // null means end + + info.needUpdate = false + + return ptrs + } + + function Box(rect: Rect) { + using $ = Sigui() + + const view = Shape.Box(wasm.memory.buffer, wasm.createBox()) satisfies Box + + view.opts = ShapeOpts.Box + view.alpha = 1.0 + + $.fx(() => { + const { x, y, w, h } = rect + $() + view.x = x + view.y = y + view.w = w + view.h = h + info.needUpdate = true + }) + + const shape = $({ + visible: true, + rect, + view, + remove() { + $.dispose() + shapes.delete(shape) + info.needUpdate = true + } + }) + + $.fx(() => { + const { visible } = shape + $() + info.needUpdate = true + }) + + shapes.add(shape) + + return shape + } + + function Notes(rect: Rect) { + using $ = Sigui() + + const view = Shape.Notes(wasm.memory.buffer, wasm.createNotes()) satisfies Notes + + view.opts = ShapeOpts.Notes + view.alpha = 1.0 + view.min = 0 + view.max = 1 + + $.fx(() => { + const { x, y, w, h } = rect + $() + view.x = x + view.y = y + view.w = w + view.h = h + info.needUpdate = true + }) + + const shape = $({ + visible: true, + rect, + view, + remove() { + $.dispose() + shapes.delete(shape) + info.needUpdate = true + } + }) + + $.fx(() => { + const { visible } = shape + $() + info.needUpdate = true + }) + + shapes.add(shape) + + return shape + } + + function Params(rect: Rect) { + using $ = Sigui() + + const view = Shape.Params(wasm.memory.buffer, wasm.createParams()) satisfies Params + + view.opts = ShapeOpts.Params + view.alpha = 1.0 + + $.fx(() => { + const { x, y, w, h } = rect + $() + view.x = x + view.y = y + view.w = w + view.h = h + info.needUpdate = true + }) + + const shape = $({ + visible: true, + rect, + view, + remove() { + $.dispose() + shapes.delete(shape) + info.needUpdate = true + } + }) + + $.fx(() => { + const { visible } = shape + $() + info.needUpdate = true + }) + + shapes.add(shape) + + return shape + } + + function Line(p0: PointLike, p1: PointLike) { + using $ = Sigui() + + const view = Shape.Line(wasm.memory.buffer, wasm.createLine()) satisfies Line + + view.opts = ShapeOpts.Line + view.alpha = 1.0 + view.lw = 1.0 + + $.fx(() => { + const { x, y } = p0 + $() + view.x0 = x + view.y0 = y + info.needUpdate = true + }) + + $.fx(() => { + const { x, y } = p1 + $() + view.x1 = x + view.y1 = y + info.needUpdate = true + }) + + const shape = $({ + visible: true, + p0, + p1, + view, + remove() { + $.dispose() + shapes.delete(shape) + info.needUpdate = true + } + }) + + $.fx(() => { + const { visible } = shape + $() + info.needUpdate = true + }) + + shapes.add(shape) + + return shape + } + + function Wave(rect: Rect) { + using $ = Sigui() + + const view = Shape.Wave(wasm.memory.buffer, wasm.createWave()) satisfies Wave + + view.opts = ShapeOpts.Wave + view.alpha = 1.0 + view.lw = 1.0 + view.coeff = 1.0 + + $.fx(() => { + const { x, y, w, h } = rect + $() + view.x = x + view.y = y + view.w = w + view.h = h + info.needUpdate = true + }) + + const shape = $({ + visible: true, + rect, + view, + remove() { + $.dispose() + shapes.delete(shape) + info.needUpdate = true + }, + }) + + $.fx(() => { + const { visible } = shape + $() + info.needUpdate = true + }) + + shapes.add(shape) + + return shape + } + + return { + info, mat2d, view, shapes, clear, update, + Box, Line, Wave, Notes, Params, + } +} diff --git a/src/as/gfx/sketch-info.ts b/src/as/gfx/sketch-info.ts new file mode 100644 index 0000000..fc93e7a --- /dev/null +++ b/src/as/gfx/sketch-info.ts @@ -0,0 +1,83 @@ +import { type Shapes, fragment, MeshInfo, Rect, vertex, wasm } from 'gfx' +import { GL } from 'gl-util' +import { Sigui } from 'sigui' +import { MAX_GL_INSTANCES } from '~/as/assembly/gfx/sketch-shared.ts' + +const DEBUG = false + +export function SketchInfo(GL: GL, view: Rect) { + using $ = Sigui() + + const { gl, attrib } = GL + + DEBUG && console.debug('[sketch-info] MAX_GL_INSTANCES:', MAX_GL_INSTANCES) + + const info = MeshInfo(GL, { + vertex, + fragment, + vao: { + a_quad: [ + gl.ARRAY_BUFFER, attrib(1, new Float32Array([0, 1, 2, 3])) + ], + a_vert: [ + gl.ARRAY_BUFFER, attrib(4, wasm.alloc(Float32Array, MAX_GL_INSTANCES * 4), 1), + gl.DYNAMIC_DRAW + ], + a_style: [ + gl.ARRAY_BUFFER, attrib(4, wasm.alloc(Float32Array, MAX_GL_INSTANCES * 4), 1), + gl.DYNAMIC_DRAW + ], + } + }) + + const { + a_vert, + a_style, + } = info.attribs + + const sketch$ = wasm.createSketch( + a_vert.ptr, + a_style.ptr, + ) + + function draw(shapes: Shapes) { + if (shapes.info.needUpdate) shapes.update() + const { mat2d, view, info: { ptrs } } = shapes + return wasm.draw( + +sketch$, + ptrs.ptr, + mat2d.byteOffset, + view.w, + view.h, + ) + } + + const range = { begin: 0, end: 0, count: 0 } + + function writeGL(count: number) { + range.end = range.count = count + GL.writeAttribRange(a_vert, range) + GL.writeAttribRange(a_style, range) + // DEBUG && log('[sketch-info] write gl begin:', range.begin, 'end:', range.end, 'count:', range.count) + } + + function finish() { + wasm.flushSketch(+sketch$) + } + + $.fx(() => { + const { pr, w_pr, h_pr } = view + $() + info.use() + gl.uniform1f(info.uniforms.u_pr, pr) + gl.uniform2f(info.uniforms.u_screen, w_pr, h_pr) + }) + + return { + info, + range, + writeGL, + draw, + finish, + } +} diff --git a/src/as/gfx/sketch.ts b/src/as/gfx/sketch.ts new file mode 100644 index 0000000..5f0894f --- /dev/null +++ b/src/as/gfx/sketch.ts @@ -0,0 +1,33 @@ +import { SketchInfo, wasm, type Rect, type Shapes } from 'gfx' +import { GL } from 'gl-util' + +const DEBUG = false + +export type Sketch = ReturnType + +export function Sketch(GL: GL, view: Rect) { + const sketch = SketchInfo(GL, view) + const scene = new Set() + + const { gl } = GL + const { info, finish, writeGL, draw: sketchDraw } = sketch + const { use } = info + + function flush(count: number) { + DEBUG && console.log('[sketch] flush', count) + writeGL(count) + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count) + } + + function draw() { + use() + wasm.setFlushSketchFn(flush) + DEBUG && console.log('[sketch] draw', scene.size) + for (const shapes of scene) { + sketchDraw(shapes) + } + finish() + } + + return { draw, scene, info, view } +} diff --git a/src/as/gfx/types.ts b/src/as/gfx/types.ts new file mode 100644 index 0000000..efbdc4a --- /dev/null +++ b/src/as/gfx/types.ts @@ -0,0 +1,71 @@ +import { $, type Signal } from 'sigui' +import { screen } from '~/src/screen.ts' + +export type Matrix = ReturnType + +class MatrixInfo { + a = 1 + b = 0 + c = 0 + d = 1 + e = 0 + f = 0 + _values?: [ + sx: number, cy: number, + cx: number, sy: number, + tx: number, ty: number, + ] + get values() { + const { a, b, c, d, e, f } = this + const o = (this._values ??= [ + 1, 0, 0, + 1, 0, 0, + ]) + o[0] = a + o[1] = b + o[2] = c + o[3] = d + o[4] = e + o[5] = f + return this._values + } +} + +export function Matrix( + a: number | Signal = 1, + b: number | Signal = 0, + c: number | Signal = 0, + d: number | Signal = 1, + e: number | Signal = 0, + f: number | Signal = 0, +) { + return $(new MatrixInfo(), { a, b, c, d, e, f }) +} + +export type Rect = ReturnType + +class RectInfo { + pr = 0 + x = 0 + y = 0 + w = 0 + h = 0 + get x_pr() { return this.x * this.pr } + get y_pr() { return this.y * this.pr } + get w_pr() { return this.w * this.pr } + get h_pr() { return this.h * this.pr } + width = $.alias(this, 'w') + height = $.alias(this, 'h') +} + +export function Rect( + x: number | Signal = 0, + y: number | Signal = 0, + w: number | Signal = 0, + h: number | Signal = 0, +) { + return $(new RectInfo(), { + pr: screen.$.pr, + x, y, w, h + }) +} diff --git a/src/as/gfx/wasm-matrix.ts b/src/as/gfx/wasm-matrix.ts new file mode 100644 index 0000000..071853a --- /dev/null +++ b/src/as/gfx/wasm-matrix.ts @@ -0,0 +1,22 @@ +import { wasm, type Matrix, type Rect } from 'gfx' +import { Sigui } from 'sigui' + +const DEBUG = false + +export type WasmMatrix = ReturnType + +export function WasmMatrix(view: Rect, matrix: Matrix) { + using $ = Sigui() + + const mat2d = new Float64Array(wasm.memory.buffer, wasm.createMatrix(), 6) + + $.fx(() => { + const { a, d, e, f } = matrix + const { pr, h } = view + $() + mat2d.set(matrix.values) + DEBUG && console.log(a) + }) + + return mat2d +} diff --git a/src/as/gfx/wasm.ts b/src/as/gfx/wasm.ts new file mode 100644 index 0000000..994a55a --- /dev/null +++ b/src/as/gfx/wasm.ts @@ -0,0 +1,36 @@ +import { instantiate } from '~/as/build/gfx.js' +import url from '~/as/build/gfx.wasm?url' +import { hexToBinary, initWasm } from '~/src/as/init-wasm.ts' + +const DEBUG = false + +let mod: WebAssembly.Module + +if (import.meta.env && import.meta.env.MODE !== 'production') { + const hex = (await import('~/as/build/gfx.wasm?raw-hex')).default + const wasmMapUrl = new URL('/as/build/gfx.wasm.map', location.origin).href + const binary = hexToBinary(hex, wasmMapUrl) + mod = await WebAssembly.compile(binary) +} +else { + mod = await WebAssembly.compileStreaming(fetch(new URL(url, location.href))) +} + +let flushSketchFn = (count: number) => { } +function setFlushSketchFn(fn: (count: number) => void) { + flushSketchFn = fn +} + +const wasmInstance = await instantiate(mod, { + env: { + log: console.log, + flushSketch(count: number) { + DEBUG && console.debug('[flush]', count) + flushSketchFn(count) + } + } +}) + +const { alloc } = initWasm(wasmInstance) + +export const wasm = Object.assign(wasmInstance, { alloc, setFlushSketchFn }) diff --git a/src/as/init-wasm.ts b/src/as/init-wasm.ts new file mode 100644 index 0000000..d80a0b6 --- /dev/null +++ b/src/as/init-wasm.ts @@ -0,0 +1,113 @@ +import { wasmSourceMap, type TypedArray, type TypedArrayConstructor } from 'utils' + +const DEBUG = false + +interface Wasm { + memory: WebAssembly.Memory + __pin(ptr: number): number + __unpin(ptr: number): void + __new(size: number, id: number): number + __collect(): void + allocI32(bytes: number): number + allocU32(bytes: number): number + allocF32(bytes: number): number +} + +function fromHexString(hexString: string) { + return Uint8Array.from( + hexString.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)) + ) +} + +export function hexToBinary(hex: string, wasmMapUrl: string) { + const uint8 = fromHexString(hex) + const buffer = wasmSourceMap.setSourceMapURL(uint8.buffer, wasmMapUrl) + const binary = new Uint8Array(buffer) + return binary +} + +export function initWasm(wasm: Wasm) { + const reg = new FinalizationRegistry((ptr: number) => { + lru.delete(ptr) + try { + DEBUG && console.log('Freeing', ptr) + wasm.__unpin(ptr) + } + catch (error) { + console.error('Failed free:', ptr, error) + } + }) + + let lru = new Set() + const TRIES = 16 + const GC_EVERY = 1024000 + let allocs = 0 + + const funcs = new Map([ + [Int32Array, wasm.allocI32], + [Uint32Array, wasm.allocU32], + [Float32Array, wasm.allocF32], + ] as any) as any + + function alloc(ctor: T, length: number) { + const bytes = length * ctor.BYTES_PER_ELEMENT + + allocs += length + if (allocs > GC_EVERY) { + wasm.__collect() + allocs = 0 + } + + do { + try { + const ptr = funcs.has(ctor) + ? wasm.__pin(funcs.get(ctor)(length)) + : wasm.__pin(wasm.__new(bytes, 1)) + const arr = new ctor(wasm.memory.buffer, ptr, length) + const unreg = {} + reg.register(arr, ptr, unreg) + return Object.assign(arr as TypedArray, { + ptr, + free() { + reg.unregister(unreg) + lru.delete(ptr) + try { + wasm.__unpin(ptr) + } + catch (error) { + console.warn(error) + } + } + }) + } + catch (err) { + console.error(err) + console.error('Failed alloc:', bytes, ' - will attempt to free memory.') + const [first, ...rest] = lru + lru = new Set(rest) + try { + wasm.__unpin(first) + } + catch (error) { + console.warn(error) + } + // wasm.__collect() + continue + } + } while (lru.size) + + // + // NOTE: We can't allocate any wasm memory. + // This is a catastrophic error so we choose to _refresh_ the page. + // Might not be ideal in all situations. + // We shouldn't refresh if the failure is right after a new refresh, + // otherwise we enter into infinite refreshes loop. + if (+new Date() - +new Date(performance.timeOrigin) > 2_000) { + location.href = location.href + } + + throw new Error('Cannot allocate wasm memory.') + } + + return { alloc } +} diff --git a/src/as/pkg/wasm.ts b/src/as/pkg/wasm.ts index fe758b2..066e5d3 100644 --- a/src/as/pkg/wasm.ts +++ b/src/as/pkg/wasm.ts @@ -1,24 +1,13 @@ -import { wasmSourceMap, type TypedArray, type TypedArrayConstructor } from 'utils' import { instantiate } from '~/as/build/pkg.js' import url from '~/as/build/pkg.wasm?url' - -const DEBUG = false - -const { log } = console +import { hexToBinary, initWasm } from '~/src/as/init-wasm.ts' let mod: WebAssembly.Module if (import.meta.env && import.meta.env.MODE !== 'production') { const hex = (await import('~/as/build/pkg.wasm?raw-hex')).default - const fromHexString = (hexString: string) => Uint8Array.from( - hexString.match(/.{1,2}/g)!.map(byte => - parseInt(byte, 16) - ) - ) const wasmMapUrl = new URL('/as/build/pkg.wasm.map', location.origin).href - const uint8 = fromHexString(hex) - const buffer = wasmSourceMap.setSourceMapURL(uint8.buffer, wasmMapUrl) - const binary = new Uint8Array(buffer) + const binary = hexToBinary(hex, wasmMapUrl) mod = await WebAssembly.compile(binary) } else { @@ -27,101 +16,10 @@ else { const wasm = await instantiate(mod, { env: { - log, - } -}) - -const reg = new FinalizationRegistry((ptr: number) => { - lru.delete(ptr) - try { - DEBUG && console.log('Freeing', ptr) - wasm.__unpin(ptr) - } - catch (error) { - console.error('Failed free:', ptr, error) + log: console.log, } }) -let lru = new Set() -const TRIES = 16 -const GC_EVERY = 1024000 -let allocs = 0 - -const funcs = new Map([ - [Int32Array, wasm.allocI32], - [Uint32Array, wasm.allocU32], - [Float32Array, wasm.allocF32], -] as any) as any - -function alloc(ctor: T, length: number) { - const bytes = length * ctor.BYTES_PER_ELEMENT - // console.warn('[player] alloc', length) - allocs += length - if (allocs > GC_EVERY) { - // console.log('[player gc]') - wasm.__collect() - allocs = 0 - } - - do { - try { - const ptr = funcs.has(ctor) - ? wasm.__pin(funcs.get(ctor)(length)) - : wasm.__pin(wasm.__new(bytes, 1)) - const arr = new ctor(wasm.memory.buffer, ptr, length) - const unreg = {} - reg.register(arr, ptr, unreg) - return Object.assign(arr as TypedArray, { - ptr, - free() { - reg.unregister(unreg) - lru.delete(ptr) - try { - wasm.__unpin(ptr) - } - catch (error) { - console.warn(error) - } - } - }) - } - catch (err) { - console.error(err) - console.error('Failed alloc:', bytes, ' - will attempt to free memory.') - const [first, ...rest] = lru - lru = new Set(rest) - try { - wasm.__unpin(first) - } - catch (error) { - console.warn(error) - } - // wasm.__collect() - continue - } - } while (lru.size) - - // - // NOTE: We can't allocate any wasm memory. - // This is a catastrophic error so we choose to _refresh_ the page. - // Might not be ideal in all situations. - // We shouldn't refresh if the failure is right after a new refresh, - // otherwise we enter into infinite refreshes loop. - if (+new Date() - +new Date(performance.timeOrigin) > 2_000) { - location.href = location.href - } - - throw new Error('Cannot allocate wasm memory.') -} +const { alloc } = initWasm(wasm) export default Object.assign(wasm, { alloc }) - -if (import.meta.vitest) { - describe('alloc', () => { - it('works', () => { - const buf = alloc(Float32Array, 32) - expect(buf.length).toBe(32) - expect(buf).toBeInstanceOf(Float32Array) - }) - }) -} diff --git a/src/client.tsx b/src/client.tsx index 617cf66..5c70bd9 100644 --- a/src/client.tsx +++ b/src/client.tsx @@ -3,7 +3,8 @@ import { App } from '~/src/pages/App.tsx' import { setState, state } from '~/src/state.ts' export const start = mount('#container', target => { - target.replaceChildren() + state.container = target + target.replaceChildren( as HTMLElement) return cleanup }) diff --git a/src/comp/AnimMode.tsx b/src/comp/AnimMode.tsx new file mode 100644 index 0000000..233c68e --- /dev/null +++ b/src/comp/AnimMode.tsx @@ -0,0 +1,18 @@ +import { cn } from '~/lib/cn.ts' +import type { Anim } from '~/src/as/gfx/anim.ts' +import { state } from '~/src/state.ts' +import { Button } from '~/src/ui/Button.tsx' + +export function AnimMode({ anim }: { anim: Anim }) { + return
+ anim: + +
+} diff --git a/src/comp/Header.tsx b/src/comp/Header.tsx index 995db3b..70d12d6 100644 --- a/src/comp/Header.tsx +++ b/src/comp/Header.tsx @@ -1,5 +1,5 @@ export function Header({ children }: { children?: any }) { - return
+ return
{children}
} diff --git a/src/comp/Toast.tsx b/src/comp/Toast.tsx index ef201eb..8a17f3a 100644 --- a/src/comp/Toast.tsx +++ b/src/comp/Toast.tsx @@ -1,13 +1,13 @@ import { state } from '~/src/state.ts' export function Toast() { - return ( -
+ return
+ {() => state.toastMessages.length ?
{() => state.toastMessages.map(item => -
{ +
{ state.toastMessages = state.toastMessages.filter(i => i !== item) }}>{item.stack ?? item.message}
)} -
- ) +
:
} +
} diff --git a/src/env.ts b/src/env.ts index 2dd1b0c..862cae8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -7,3 +7,9 @@ const Env = z.object({ export const env = Env.parse(Object.assign({ VITE_API_URL: location.origin }, import.meta.env)) + +const url = new URL(location.origin) +if (url.port.length) { + url.port = '8000' + env.VITE_API_URL = url.href.slice(0, -1) // trim trailing slash +} diff --git a/src/lang/tokenize.test.ts b/src/lang/tokenize.test.ts new file mode 100644 index 0000000..d75927f --- /dev/null +++ b/src/lang/tokenize.test.ts @@ -0,0 +1,70 @@ +import { Token, tokenize } from '~/src/lang/tokenize.ts' + +describe('tokenize', () => { + it('simple', () => { + const source = { code: 'hello world\n123' } + const tokens = [...tokenize(source)] + expect(tokens.length).toBe(3) + expect(tokens[0]).toMatchObject({ + type: Token.Type.Id, + text: 'hello', + line: 0, + col: 0, + right: 5, + bottom: 0, + index: 0, + length: 5, + source, + }) + expect(tokens[1]).toMatchObject({ + type: Token.Type.Id, + text: 'world', + line: 0, + col: 6, + right: 11, + bottom: 0, + index: 6, + length: 5, + source, + }) + expect(tokens[2]).toMatchObject({ + type: Token.Type.Number, + text: '123', + line: 1, + col: 0, + right: 3, + bottom: 1, + index: 12, + length: 3, + source, + }) + }) + + it('multiline', () => { + const source = { code: '[; hello world\n123 ]' } + const tokens = [...tokenize(source)] + expect(tokens.length).toBe(2) + expect(tokens[0]).toMatchObject({ + type: Token.Type.Comment, + text: '[; hello world', + line: 0, + col: 0, + right: 14, + bottom: 0, + index: 0, + length: 14, + source, + }) + expect(tokens[1]).toMatchObject({ + type: Token.Type.Comment, + text: '123 ]', + line: 1, + col: 0, + right: 5, + bottom: 1, + index: 15, + length: 5, + source, + }) + }) +}) diff --git a/src/lang/tokenize.ts b/src/lang/tokenize.ts new file mode 100644 index 0000000..c2a93ff --- /dev/null +++ b/src/lang/tokenize.ts @@ -0,0 +1,233 @@ +export interface Source { + code: string +} + +export interface Token { + /** Token type. */ + type: T + /** The text representation of the token. */ + text: string + /** Zero based line number. */ + line: number + /** Zero based column number. */ + col: number + /** Rightmost column number, zero based. */ + right: number + /** Bottom line, zero based. */ + bottom: number + /** Zero based index. */ + index: number + /** Token length. */ + length: number + /** Reference to the source code document. */ + source: Source +} + +export namespace Token { + export enum Type { + Newline = 'Newline', + Realnewline = 'Realnewline', + Whitespace = 'Whitespace', + Native = 'Native', + String = 'String', + Keyword = 'Keyword', + Op = 'Op', + Id = 'Id', + Number = 'Number', + BlockComment = 'BlockComment', + Comment = 'Comment', + Any = 'Any', + } + + export const typesEntries = [ + [Type.Newline, /(\n)/], + [Type.Realnewline, /(\r)/], + [Type.Whitespace, /(\s+?)/], + [Type.Native, /(`[a-z]+`)/], + [Type.String, /('[^'\n]*['\n])/], + [Type.Keyword, /(M|S|\\|@|,)/], + [Type.Op, /(\?)/], + [Type.Id, /([a-zA-Z]+[a-zA-Z0-9_]*)/], + [Type.Number, /([0-9]+[dhk][0-9]*|[0-9]+\.[0-9]+|\.?[0-9]+)/], + [Type.BlockComment, /(\[;|\(;|{;)/], + [Type.Op, /(\*=|\+=|:=|\+|-|\*|\/|\^|%|!|=|\{|\}|\(|\)|\[|\]|:|\.)/], + [Type.Comment, /(;)/], + [Type.Any, /(.+?)/], + ] as const + + export const regexp = new RegExp( + Array.from(typesEntries, + ([, regexp]) => regexp.source + ).join('|'), + 'g' + ) + + export interface Bounds { + line: number + col: number + right: number + bottom: number + index: number + length: number + } + + export const Empty: Token = { + type: 0, + text: '', + line: 0, + col: 0, + right: 0, + bottom: 0, + index: 0, + length: 0, + source: { code: '' } + } + + export const Close = { + '[': ']', + '(': ')', + '{': '}', + } as const + + export function isTerminal(token: Token) { + switch (token.type) { + case Type.Any: + case Type.Comment: return false + } + return true + } + + export function closeOf(token: Token) { + return Close[token.text as keyof typeof Close].charCodeAt(0) + } + + export function bounds(tokens: Token[]): Bounds { + let line: number = Infinity + let col: number = Infinity + let right: number = 0 + let index: number = Infinity + let bottom: number = 0 + let end: number = 0 + + let t: { line: number, col: number, right: number, index: number, length: number } + + for (const t of tokens) { + if (t.index < index) index = t.index + if (t.index + t.length > end) end = t.index + t.length + if (t.line < line) line = t.line + if (t.line > bottom) bottom = t.line + if (t.col < col && t.line === line) col = t.col + if (t.right > right) right = t.right + } + + return { line, col, right, bottom, index, length: end - index } + } +} + +export function* tokenize(source: Source): Generator { + const code = source.code + let i = 0 + let text: string | null + let type: Token.Type + let line = 0 + let lineIndex = 0 + let depth = 0 + let col = 0 + let match: RegExpMatchArray + let begin: RegExpMatchArray + let slice: string + let open: keyof typeof Token.Close + let close: typeof Token.Close[keyof typeof Token.Close] + let commenting = false + const length = Token.typesEntries.length + 1 + const it = code.matchAll(Token.regexp) + outer: for (match of it) { + for (i = 1; i < length; i++) { + text = match[i] + if (text != null) { + type = Token.typesEntries[i - 1][0] + switch (type) { + case Token.Type.Realnewline: + commenting = false + break + case Token.Type.Whitespace: + break + case Token.Type.Newline: + ++line + lineIndex = match.index! + 1 + break + case Token.Type.BlockComment: { + type = Token.Type.Comment + begin = match + open = text[0] as keyof typeof Token.Close + close = Token.Close[open] + depth = 1 + for (const m of it) { + for (let x = 0, t: string; x < length; x++) { + t = m[x] + if (t != null) { + if (t === '\n') { + col = begin.index! - lineIndex + slice = code.slice(begin.index, m.index!) + yield { + type, + text: slice, + line, + col, + right: col + slice.length, + bottom: line, + index: begin.index!, + length: slice.length, + source + } + begin = m + + ++line + lineIndex = ++m.index! + } + else if (t === open) depth++ + else if (t === close) { + if (!--depth) { + col = begin.index! - lineIndex + slice = code.slice(begin.index, m.index! + 1) + yield { + type, + text: slice, + line, + col, + right: col + slice.length, + bottom: line, + index: begin.index!, + length: slice.length, + source + } + continue outer + } + } + break + } + } + } + break + } + case Token.Type.Comment: + commenting = true + default: + col = match.index! - lineIndex + yield { + type: commenting ? Token.Type.Comment : type, + text, + line, + col, + right: col + text.length, + bottom: line, + index: match.index!, + length: text.length, + source + } + } + break + } + } + } +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index cd456e7..a5d0635 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -8,12 +8,14 @@ import { Toast } from '~/src/comp/Toast.tsx' import { VerifyEmail } from '~/src/comp/VerifyEmail.tsx' import { About } from '~/src/pages/About.tsx' import { AssemblyScript } from '~/src/pages/AssemblyScript.tsx' -import { Canvas } from '~/src/pages/Canvas' +import { CanvasDemo } from '~/src/pages/CanvasDemo' import { Chat } from '~/src/pages/Chat/Chat.tsx' +import { EditorDemo } from '~/src/pages/EditorDemo.tsx' import { Home } from '~/src/pages/Home.tsx' import { OAuthRegister } from '~/src/pages/OAuthRegister.tsx' import { QrCode } from '~/src/pages/QrCode.tsx' import { UiShowcase } from '~/src/pages/UiShowcase.tsx' +import { WebGLDemo } from '~/src/pages/WebGLDemo.tsx' import { WebSockets } from '~/src/pages/WebSockets.tsx' import { whoami } from '~/src/rpc/auth.ts' import { state } from '~/src/state.ts' @@ -26,8 +28,8 @@ export function App() { const info = $({ bg: 'transparent', - canvasWidth: 500, - canvasHeight: 500, + canvasWidth: state.$.containerWidth, + canvasHeight: 800, }) const router = CachingRouter({ @@ -35,7 +37,9 @@ export function App() { '/ui': () => , '/chat': () => , '!/ws': () => , - '!/canvas': () => , + '!/canvas': () => , + '/webgl': () => , + '/editor': () => , '/asc': () => , '/qrcode': () => , '/about': () => , @@ -75,7 +79,7 @@ export function App() { ]) return
info.bg = '#433'} onmouseleave={() => info.bg = 'transparent'} > @@ -93,7 +97,7 @@ export function App() {
-
+
{() => { if (state.user === undefined) return
Loading...
@@ -102,6 +106,6 @@ export function App() { return
404 Not found
}} -
+ } diff --git a/src/pages/AssemblyScript.tsx b/src/pages/AssemblyScript.tsx index 754ee4b..bf9d40d 100644 --- a/src/pages/AssemblyScript.tsx +++ b/src/pages/AssemblyScript.tsx @@ -35,7 +35,7 @@ export function AssemblyScript() {
Worker: {() => info.fromWorker}
- - + + } diff --git a/src/pages/Canvas.tsx b/src/pages/CanvasDemo.tsx similarity index 59% rename from src/pages/Canvas.tsx rename to src/pages/CanvasDemo.tsx index e5b89b2..4a6656d 100644 --- a/src/pages/Canvas.tsx +++ b/src/pages/CanvasDemo.tsx @@ -1,39 +1,27 @@ import { Sigui, type Signal } from 'sigui' import { drawText } from 'utils' import { screen } from '~/src/screen.ts' +import { Canvas } from '~/src/ui/Canvas.tsx' +import { H2 } from '~/src/ui/Heading.tsx' -export function Canvas({ width, height }: { +export function CanvasDemo({ width, height }: { width: Signal height: Signal }) { using $ = Sigui() - const canvas = as HTMLCanvasElement - const c = canvas.getContext('2d')! + const canvas = as HTMLCanvasElement const info = $({ + c: null as null | CanvasRenderingContext2D, pr: screen.$.pr, width, height, - c: $.unwrap(async () => { - await document.fonts.ready - return c - }) }) - $.fx(() => { - const { c, width, height, pr } = $.of(info) - $() - if (c instanceof Error) return - canvas.width = width * pr - canvas.height = height * pr - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - c.scale(pr, pr) - c.textBaseline = 'top' - c.textRendering = 'optimizeSpeed' - c.miterLimit = 1.5 - c.font = '32px "Fustat"' + const c = canvas.getContext('2d')! + document.fonts.ready.then(() => { + info.c = c }) let i = 0 @@ -47,13 +35,25 @@ export function Canvas({ width, height }: { } $.fx(() => { - const { c } = $.of(info) + const { pr, c, width, height } = $.of(info) + $() + c.scale(pr, pr) + c.textBaseline = 'top' + c.textRendering = 'optimizeSpeed' + c.miterLimit = 1.5 + c.font = '32px "Fustat"' + }) + + $.fx(() => { + const { c, width, height } = $.of(info) $() - if (c instanceof Error) return - c.translate(info.width / 2, info.height / 2) + c.translate(width / 2, height / 2) tick() return () => cancelAnimationFrame(animFrame) }) - return canvas + return
+

Canvas demo

+ {canvas} +
} diff --git a/src/pages/Chat/Channels.tsx b/src/pages/Chat/Channels.tsx index 14c3c18..ae76782 100644 --- a/src/pages/Chat/Channels.tsx +++ b/src/pages/Chat/Channels.tsx @@ -6,14 +6,14 @@ import { cn } from '~/lib/cn.ts' import { icon } from '~/lib/icon.ts' import * as actions from '~/src/rpc/chat.ts' import { state } from '~/src/state.ts' -import { byName, hasChannel } from './util.ts' import { H3 } from '~/src/ui/Heading.tsx' +import { byName, hasChannel } from './util.ts' export function Channels({ overlay = true }: { overlay?: boolean }) { using $ = Sigui() return

Channels @@ -62,15 +62,14 @@ export function Channels({ overlay = true }: { overlay?: boolean }) { onpointerdown={() => state.currentChannelName = channel.name} > {channel.name} - + as HTMLButtonElement if (isCurrent) { requestAnimationFrame(() => { el.scrollIntoView({ block: 'center' }) }) } return el - } - )} + })}

} diff --git a/src/pages/Chat/Chat.tsx b/src/pages/Chat/Chat.tsx index f9d26aa..9f51138 100644 --- a/src/pages/Chat/Chat.tsx +++ b/src/pages/Chat/Chat.tsx @@ -149,12 +149,16 @@ export function Chat() { .catch(console.error) }) - return
+ const messages = Messages({ showChannelsOverlay: info.$.showChannelsOverlay }) + + const el =
{() => screen.md || info.showChannelsOverlay ? :
} - + + {messages.el} + {() => screen.md ? { info.videoCallType = 'offer' info.videoCallTargetNick = nick @@ -170,4 +174,6 @@ export function Chat() {
}
+ + return { el, focus: messages.focus } } diff --git a/src/pages/Chat/Messages.tsx b/src/pages/Chat/Messages.tsx index 627038f..1b90e68 100644 --- a/src/pages/Chat/Messages.tsx +++ b/src/pages/Chat/Messages.tsx @@ -1,5 +1,6 @@ import { LogOut, Menu } from 'lucide' -import { refs, Sigui, type Signal } from 'sigui' +import { Sigui, type Signal } from 'sigui' +import { dom } from 'utils' import type { ChatMessage } from '~/api/chat/types.ts' import { cn } from '~/lib/cn.ts' import { icon } from '~/lib/icon.ts' @@ -16,26 +17,54 @@ export function Messages({ showChannelsOverlay }: { showChannelsOverlay: Signal< showChannelsOverlay, }) + const input = e.key === 'Enter' && sendMessage()} + /> as HTMLInputElement + + const chatMessages =
+
+ {() => emptyMsg.concat(state.currentChannel?.messages ?? []).concat(emptyMsg).map(message => +
+ {message.nick} + {message.type === 'join' ? 'joined #' + state.currentChannelName : message.text}{!message.text.length && <> } +
+ )} +
+
as HTMLDivElement + + function scrollToBottom() { + chatMessages.scrollTo({ + top: chatMessages.scrollHeight, + behavior: 'instant', + }) + } + + function focus() { + scrollToBottom() + input.focus({ preventScroll: true }) + } + $.fx(() => { const { currentChannel } = $.of(state) const { messages } = currentChannel $() - requestAnimationFrame(() => { - refs.chatMessages.scrollTo({ - top: refs.chatMessages.scrollHeight, - behavior: 'instant', - }) - - const input = refs.chatInput as HTMLInputElement - input.focus() - }) + requestAnimationFrame(focus) }) + $.fx(() => dom.on(input, 'focus', () => { + setTimeout(scrollToBottom, 150) + })) + function sendMessage() { if (!state.currentChannel || !state.currentChannelName) return - const input = refs.chatInput as HTMLInputElement - const message: ChatMessage = { type: 'message', channel: state.currentChannelName, @@ -54,8 +83,8 @@ export function Messages({ showChannelsOverlay }: { showChannelsOverlay: Signal< const emptyMsg: ChatMessage[] = [{ type: 'message', nick: '', text: '' }] - return
info.showChannelsOverlay = false}>

@@ -80,21 +109,13 @@ export function Messages({ showChannelsOverlay }: { showChannelsOverlay: Signal<

-
-
- {() => emptyMsg.concat(state.currentChannel?.messages ?? []).concat(emptyMsg).map(message => -
- {message.nick} - {message.type === 'join' ? 'joined #' + state.currentChannelName : message.text}{!message.text.length && <> } -
- )} -
-
+ {chatMessages} +
{() => state.user?.nick} - e.key === 'Enter' && sendMessage()} - /> + {input}
+ + return { el, focus } } diff --git a/src/pages/Chat/Users.tsx b/src/pages/Chat/Users.tsx index b2afeb7..00049a5 100644 --- a/src/pages/Chat/Users.tsx +++ b/src/pages/Chat/Users.tsx @@ -1,6 +1,4 @@ -import { refs, Sigui } from 'sigui' import { colorizeNick } from '~/src/pages/Chat/util.ts' -import * as actions from '~/src/rpc/chat.ts' import { state } from '~/src/state.ts' import { H3 } from '~/src/ui/Heading.tsx' import { Link } from '~/src/ui/Link.tsx' @@ -12,7 +10,7 @@ export function Users({ onUserClick }: { const { nick } = state.user - return
+ return

Users

diff --git a/src/pages/Chat/VideoCall.tsx b/src/pages/Chat/VideoCall.tsx index 4763565..92ba8ee 100644 --- a/src/pages/Chat/VideoCall.tsx +++ b/src/pages/Chat/VideoCall.tsx @@ -38,8 +38,8 @@ export function VideoCall({ type, targetNick, remoteSdp }: { micOn: true, }) - const el =
-
+ const el =
+
diff --git a/src/pages/EditorDemo.tsx b/src/pages/EditorDemo.tsx new file mode 100644 index 0000000..7242f2b --- /dev/null +++ b/src/pages/EditorDemo.tsx @@ -0,0 +1,358 @@ +import { pointToLinecol, type Pane, type WordWrapProcessor } from 'editor' +import { wasm } from 'gfx' +import { Sigui, type Signal } from 'sigui' +import { assign, clamp } from 'utils' +import { AnimMode } from '~/src/comp/AnimMode.tsx' +import { Token, tokenize } from '~/src/lang/tokenize.ts' +import { screen } from '~/src/screen.ts' +import { theme } from '~/src/theme.ts' +import { Editor } from '~/src/ui/Editor.tsx' +import { makeWaveform, waveform } from '~/src/ui/editor/util/waveform.ts' +import { HoverMarkWidget, WaveCanvasWidget } from '~/src/ui/editor/widgets/index.ts' +import { WaveGlWidget } from '~/src/ui/editor/widgets/wave-gl.ts' +import { WaveSvgWidget } from '~/src/ui/editor/widgets/wave-svg.tsx' +import { H2 } from '~/src/ui/Heading.tsx' + +export function EditorDemo({ width, height }: { + width: Signal + height: Signal +}) { + using $ = Sigui() + + const info = $({ + c: null as null | CanvasRenderingContext2D, + pr: screen.$.pr, + width, + height, + code: `\ +[dly 16 0.555 /] +[sin 3] + [tri 111] [tri 222] [tri 333] [tri 444] [tri 555] [tri 666] + [saw 123] + [sqr 555] @ +[lp 300 .8] +[ppd (3 4 5)] +[ar 10 50] *`, + }) + + const mouse = { x: 0, y: 0 } + let number: RegExpMatchArray | undefined + let value: number + let digits: number + let isDot = false + const hoverMark = HoverMarkWidget() + + function getHoveringNumber(pane: Pane) { + const word = pane.buffer.wordUnderLinecol(pane.mouse.info.linecol) + if (word != null) { + digits = word[0].split('.')[1]?.length ?? 0 + let string = parseFloat(word[0]).toFixed(digits) + isDot = word[0][0] === '.' + if (isDot && string === '0') string = '0.0' + if (string === `${isDot ? '0' : ''}${word[0]}`) { + string = parseFloat(word[0]).toFixed(digits) + return word + } + } + } + + function updateNumberMark(pane: Pane) { + const temp = getHoveringNumber(pane) + if (temp && !pane.info.isHoveringScrollbarX && !pane.info.isHoveringScrollbarY) { + const number = temp + pane.view.el.style.cursor = 'default' + const linecol = pointToLinecol(pane.buffer.indexToVisualPoint(number.index)) + assign( + hoverMark.widget.bounds, + linecol, + { + right: pointToLinecol(pane.buffer.indexToVisualPoint(number.index + number[0].length)).col, + length: number[0].length, + bottom: linecol.line, + } + ) + pane.draw.widgets.mark.add(hoverMark.widget) + pane.draw.info.triggerUpdateTokenDrawInfo++ + pane.view.anim.info.epoch++ + } + else { + pane.draw.widgets.mark.delete(hoverMark.widget) + pane.view.anim.info.epoch++ + pane.view.el.style.cursor = pane.view.info.cursor + } + } + + function handleNumberDrag(dx: number, dy: number) { + if (number?.index == null) return + + if (Math.abs(dx) + Math.abs(dy) < .5) return + + let s: string + + let dv = + Math.max(.001, Math.abs(dx) ** 1.4) * Math.sign(dx) + + Math.max(.001, Math.abs(dy) ** 1.4) * Math.sign(dy) + + if (dv === 0) dv = 0.001 * (dy > dx ? Math.sign(dy) : Math.sign(dx)) + if (dv === 0) dv = 0.001 + if (dv > 0) dv = Math.max(0.001, dv) + if (dv < 0) dv = Math.min(-0.001, dv) + + let min: number + let max: number + let mul = 0.0017 + if (value >= 100 && value < 1000) { + min = 100 + max = parseFloat('999.' + (digits ? '9'.repeat(digits) : '0')) + } + else if (value >= 10 && value < 100) { + min = 10 + max = parseFloat('99.' + (digits ? '9'.repeat(digits) : '0')) + } + else if (value >= 1 && value < 10) { + min = 1 + max = parseFloat('9.' + (digits ? '9'.repeat(digits) : '0')) + if (!digits) mul = 0.005 + } + else if (value >= 0 && value < 1) { + min = 0 + max = parseFloat('0.' + (digits ? '9'.repeat(digits) : '0')) + if (digits === 1) mul = 0.005 + } + else { + return + } + + const scale = max - min + let normal = (value - min) / scale + normal = clamp(0, 1, normal - dv * mul) + value = normal * scale + min + s = value.toFixed(digits) + if (isDot) s = s.slice(1) + const { code } = pane.buffer + pane.buffer.code = `${code.slice(0, number.index)}${s}${code.slice(number.index + s.length)}` + } + + function onKeyDown(pane: Pane) { + queueMicrotask(() => { + updateNumberMark(pane) + }) + } + + function onKeyUp(pane: Pane) { + updateNumberMark(pane) + if (!pane.kbd.info.ctrl) { + number = void 0 + } + } + + function onMouseWheel(pane: Pane) { + let ret = false + + if (pane.mouse.info.ctrl || pane.kbd.info.ctrl) { + updateNumberMark(pane) + if (number?.index != null || onMouseDown(pane)) ret = true + if (ret) { + handleNumberDrag( + -pane.mouse.info.wheel.x * .06, + -pane.mouse.info.wheel.y * .06 + ) + } + } + else { + number = void 0 + } + + return ret + } + + function onMouseDown(pane: Pane) { + number = getHoveringNumber(pane) + if (number?.index == null) return + value = parseFloat(number[0]) + mouse.x = pane.mouse.info.x + mouse.y = pane.mouse.info.y + return true + } + + function onMouseMove(pane: Pane) { + if (number?.index == null) { + updateNumberMark(pane) + return + } + const p = pane.mouse.info + const dx = mouse.x - p.x + const dy = p.y - mouse.y + mouse.x = p.x + mouse.y = p.y + handleNumberDrag(dx, dy) + return true + } + + function onMouseUp(pane: Pane) { + if (number) { + number = void 0 + return true + } + } + + const inputHandlers = { + onKeyDown, + onKeyUp, + onMouseWheel, + onMouseDown, + onMouseUp, + onMouseMove, + } + + const colors: Partial> = { + [Token.Type.Native]: { fill: theme.colors.sky[500], stroke: theme.colors.sky[500] }, + [Token.Type.String]: { fill: theme.colors.fuchsia[700], stroke: theme.colors.fuchsia[700] }, + [Token.Type.Keyword]: { fill: theme.colors.orange[500], stroke: theme.colors.orange[500] }, + [Token.Type.Op]: { fill: theme.colors.sky[500], stroke: theme.colors.sky[500] }, + [Token.Type.Id]: { fill: theme.colors.yellow[500], stroke: theme.colors.yellow[500] }, + [Token.Type.Number]: { fill: theme.colors.green[500], stroke: theme.colors.green[500] }, + [Token.Type.BlockComment]: { fill: theme.colors.neutral[700], stroke: theme.colors.neutral[700] }, + [Token.Type.Comment]: { fill: theme.colors.neutral[700], stroke: theme.colors.neutral[700] }, + [Token.Type.Any]: { fill: theme.colors.neutral[500], stroke: theme.colors.neutral[500] }, + } + + function colorize(token: Token) { + const { fill = '#888', stroke = '#888' } = colors[token.type] ?? {} + return { fill, stroke } + } + + const wordWrapProcessor: WordWrapProcessor = { + pre(input: string) { + return input.replace(/\[(\w+)([^\]\n]+)\]/g, (_: any, word: string, chunk: string) => { + const c = chunk.replace(/\s/g, '\u0000') + return `[${word}${c}]` + }) + }, + post(input: string) { + return input.replaceAll('\u0000', ' ') + } + } + + const editor = Editor({ + width: info.$.width, + height: info.$.height, + code: info.$.code, + colorize, + tokenize, + wordWrapProcessor, + inputHandlers, + }) + + // const pane2Info = $({ + // code: `[hello] + // [world] + // [world] + // [world] + // [world] + // [world] + // [world] + // [world] + // [world] + // ` + // }) + // const pane2 = editor.createPane({ + // rect: $(Rect(), { x: 0, y: 240, w: 200, h: 200 }), + // code: pane2Info.$.code, + // }) + // editor.addPane(pane2) + + // /////////////////// + const floats = Object.assign( + wasm.alloc(Float32Array, waveform.length), + { len: waveform.length } + ) + floats.set(waveform) + + const [pane] = editor.info.panes + // editor.view.gfx.matrix.f = 20 + // pane.dims.info.scrollY = -20 + $.fx(() => { + const { tokens } = pane.buffer.info + $() + const gens: Token[][] = [] + + let depth = 0 + let gen: Token[] = [] + for (const token of tokens) { + if (token.text === '[') { + depth++ + } + else if (token.text === ']') { + gen.push(token) + depth-- + if (!depth) { + gens.push(gen) + gen = [] + } + } + if (depth) gen.push(token) + } + + if (gens.length < 2) return + + const d = WaveCanvasWidget() + d.info.floats = floats + Object.assign(d.widget.bounds, Token.bounds(gens[0])) + pane.draw.widgets.deco.add(d.widget) + + const d2 = WaveGlWidget(pane.draw.shapes) + d2.info.floats = floats + Object.assign(d2.widget.bounds, Token.bounds(gens[1])) + pane.draw.widgets.deco.add(d2.widget) + + const d3 = WaveSvgWidget() + d3.info.floats = floats + Object.assign(d3.widget.bounds, Token.bounds(gens[2])) + pane.draw.widgets.deco.add(d3.widget) + + const paneSvg = `${-pane.dims.info.scrollX} ${-pane.dims.info.scrollY} ${pane.dims.info.rect.w} ${pane.dims.info.rect.h}`} + + /> as SVGSVGElement + paneSvg.append(d3.svg) + editor.view.info.svgs.add(paneSvg) + editor.view.info.svgs = new Set(editor.view.info.svgs) + + return () => { + pane.draw.widgets.deco.delete(d.widget) + pane.draw.widgets.deco.delete(d2.widget) + d2.dispose() + pane.draw.widgets.deco.delete(d3.widget) + + editor.view.info.svgs.delete(paneSvg) + editor.view.info.svgs = new Set(editor.view.info.svgs) + } + }) + pane.draw.widgets.update() + + // const d2 = WaveGlWidget(pane2.draw.shapes) + // d2.info.floats = floats + // Object.assign(d2.widget.bounds, { line: 0, col: 0, right: 5, bottom: 0 }) + // pane2.draw.widgets.deco.add(d2.widget) + // pane2.draw.widgets.update() + + let t = 101 + floats.set(makeWaveform(2048, t += 1, 1 + Math.sin(t * 0.025) * 59)) + + /////////////////// + + const el =
+
+

Editor demo

+ +
+ {editor} +
+ + return { el, focus: editor.focus } +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index d1571fb..ef8afe7 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -26,6 +26,8 @@ export function Home() { Chat WebSockets Canvas + WebGL + Editor AssemblyScript QrCode About diff --git a/src/pages/UiShowcase.tsx b/src/pages/UiShowcase.tsx index ea4700d..5e82027 100644 --- a/src/pages/UiShowcase.tsx +++ b/src/pages/UiShowcase.tsx @@ -9,7 +9,7 @@ function UiGroup({ name, children }: { name: string, children?: any }) { export function UiShowcase() { return ( -
+

UI Showcase

diff --git a/src/pages/WebGLDemo.tsx b/src/pages/WebGLDemo.tsx new file mode 100644 index 0000000..3197322 --- /dev/null +++ b/src/pages/WebGLDemo.tsx @@ -0,0 +1,86 @@ +import { Anim, Gfx, Matrix, Rect } from 'gfx' +import { Sigui, type Signal } from 'sigui' +import { AnimMode } from '~/src/comp/AnimMode.tsx' +import { Canvas } from '~/src/ui/Canvas.tsx' +import { H2 } from '~/src/ui/Heading.tsx' + +export function WebGLDemo({ width, height }: { + width: Signal + height: Signal +}) { + using $ = Sigui() + + const canvas = as HTMLCanvasElement + + const gfx = Gfx({ canvas }) + + const anim = Anim() + // anim.ticks.add(gfx.draw) + + { + const view = Rect(0, 0, 500, 500) + const matrix = Matrix() + const ctx = gfx.createContext(view, matrix) + const shapes = ctx.createShapes() + ctx.sketch.scene.add(shapes) + anim.ticks.add(ctx.meshes.draw) + + function Box() { + const boxRect = Rect(10, 10, 20, 20) + const box = shapes.Box(boxRect) + box.view.color = 0xffffff + return box + } + + $.fx(() => { + $() + const box = Box() + box.view.color = Math.random() * 0xffffff + let t = 0 + function tick() { + box.view.x = Math.sin(t) * 100 + 100 + t += 0.1 + return true + } + anim.ticks.add(tick) + }) + } + + { + const view = Rect(0, 30, 500, 500) + const matrix = Matrix() + const ctx = gfx.createContext(view, matrix) + const shapes = ctx.createShapes() + ctx.sketch.scene.add(shapes) + anim.ticks.add(ctx.meshes.draw) + + function Box() { + const boxRect = Rect(10, 30, 20, 20) + const box = shapes.Box(boxRect) + // box.view.opts |= + box.view.color = 0xffffff + return box + } + + $.fx(() => { + $() + const box = Box() + box.view.color = Math.random() * 0xffffff + let t = 0 + function tick() { + box.view.x = Math.sin(t) * 100 + 100 + t += 0.1 + return true + } + anim.ticks.add(tick) + }) + } + + return
+
+

WebGL Demo

+ +
+ {canvas} +
+} diff --git a/src/pages/WebSockets.tsx b/src/pages/WebSockets.tsx index 008d390..b4b41ff 100644 --- a/src/pages/WebSockets.tsx +++ b/src/pages/WebSockets.tsx @@ -1,5 +1,5 @@ import { Sigui } from 'sigui' -import { dom } from 'utils' +import { dom, isMobile } from 'utils' import { createWebSocket } from '~/lib/ws.ts' import { env } from '~/src/env.ts' import { colorizeNick } from '~/src/pages/Chat/util.ts' @@ -11,7 +11,7 @@ export function WebSockets() { const ws = createWebSocket('/ws', env.VITE_API_URL) $.fx(() => () => ws.close()) - const el =
+ const el =
as HTMLDivElement const pointers = new Map() ws.onmessage = ({ data }) => { @@ -26,11 +26,19 @@ export function WebSockets() { pointer.style.top = y + 'px' } - $.fx(() => dom.on(window, 'pointermove', ev => { - if (state.user && ws.state() == 'open') { - ws.send(`${state.user.nick},${ev.pageX.toFixed(1)},${ev.pageY.toFixed(1)}`) - } - })) + $.fx(() => [ + dom.on(el, isMobile() ? 'touchmove' : 'pointermove', ev => { + ev.preventDefault() + + const p: { pageX: number, pageY: number } = ev.type === 'touchmove' + ? (ev as TouchEvent).touches[0]! + : ev as PointerEvent + + if (state.user && ws.state() == 'open') { + ws.send(`${state.user.nick},${p.pageX.toFixed(1)},${p.pageY.toFixed(1)}`) + } + }, { passive: false }) + ].filter(Boolean)) return el } diff --git a/src/screen.ts b/src/screen.ts index 962cd0c..4671272 100644 --- a/src/screen.ts +++ b/src/screen.ts @@ -3,8 +3,8 @@ import { dom } from 'utils' export const screen = $({ pr: window.devicePixelRatio, - width: window.innerWidth, - height: window.innerHeight, + width: window.visualViewport!.width, + height: window.visualViewport!.height, get sm() { return screen.width < 640 }, @@ -16,8 +16,15 @@ export const screen = $({ }, }) -dom.on(window, 'resize', () => { - screen.pr = window.devicePixelRatio - screen.width = window.innerWidth - screen.height = window.innerHeight -}, { unsafeInitial: true }) +$.fx(() => [ + dom.on(window, 'resize', $.fn(() => { + const viewport = window.visualViewport! + screen.pr = window.devicePixelRatio + screen.width = viewport.width + screen.height = viewport.height + }), { unsafeInitial: true }), + + dom.on(document, 'focus', () => { + console.log('trigger focus') + }) +]) diff --git a/src/state.ts b/src/state.ts index dca5d91..36a0b12 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,56 +1,90 @@ -import { $ } from 'sigui' +import { $, storage } from 'sigui' import type { z } from 'zod' import type { UserSession } from '~/api/auth/types.ts' import type { UiChannel } from '~/api/chat/types.ts' import type { Channels } from '~/api/models.ts' +import { lorem, loremRandomWord } from '~/lib/lorem.ts' +import { AnimMode } from '~/src/as/gfx/anim.ts' import { env } from '~/src/env.ts' +import { screen } from '~/src/screen.ts' import { link } from '~/src/ui/Link.tsx' -export let state = $({ - user: undefined as undefined | null | UserSession, +class State { + // container + container: HTMLElement | null = null + get containerWidth(): number { + const { width } = screen + const { container } = this + if (!container) return width + const article = container.getElementsByTagName('article')[0] as HTMLElement + const style = window.getComputedStyle(article) + return article.getBoundingClientRect().width + - parseFloat(style.paddingLeft) + - parseFloat(style.paddingRight) + } - url: link.$.url, - get pathname() { + // url + url: typeof link.$.url + get pathname(): string { return state.url.pathname - }, - get search() { + } + get search(): string { return state.url.search - }, - get searchParams() { - return new URLSearchParams(state.search) - }, - get apiUrl() { + } + get searchParams(): URLSearchParams { + return new URLSearchParams(this.search) + } + get apiUrl(): string { const url = new URL(env.VITE_API_URL) - if (state.search.includes('api2')) { + if (this.search.includes('api2')) { url.port = '8001' } return url.href - }, + } - channelsList: [] as Pick, 'name'>[], - channels: [] as UiChannel[], - currentChannelName: null as null | string, - get currentChannel() { - return state.channels.find(c => c.name === state.currentChannelName) - }, + // app + user?: UserSession | null - toastMessages: [] as { message?: string, stack?: string }[], -}) + channelsList: Pick, 'name'>[] = [] + channels: UiChannel[] = [] + currentChannelName?: string | null + + get currentChannel(): UiChannel | undefined { + return this.channels.find(c => c.name === this.currentChannelName) + } + + toastMessages: { message?: string, stack?: string }[] = [] + + animMode = storage(AnimMode.Auto) + animCycle?: () => void + + constructor() { + this.url = link.$.url + } +} + +export let state = $(new State) export function setState(newState: any) { state = newState } -// const channels = ['general', 'random', 'dev'] -// // const channels = Array.from({ length: 50 + (Math.random() * 50 | 0) }).map(() => loremRandomWord()) -// state.channels = channels.sort().map(name => $({ -// name, -// users: Array.from({ length: 50 + (Math.random() * 50 | 0) }).map(() => ({ -// nick: loremRandomWord(), -// })), -// messages: Array.from({ length: 100 }).map((_, i) => ({ -// nick: loremRandomWord(), -// text: lorem((Math.random() ** 2.5) * 20 + 1), -// })) -// })) - -// state.currentChannelName = state.channels[0].name + +function seedFakeChannels() { + const channels = ['general', 'random', 'dev'] + // const channels = Array.from({ length: 50 + (Math.random() * 50 | 0) }).map(() => loremRandomWord()) + state.channels = channels.sort().map(name => $({ + name, + users: Array.from({ length: 50 + (Math.random() * 50 | 0) }).map(() => ({ + nick: loremRandomWord(), + })), + messages: Array.from({ length: 100 }).map((_, i) => ({ + type: 'message', + nick: loremRandomWord(), + text: lorem((Math.random() ** 2.5) * 20 + 1), + })) + })) + + state.currentChannelName = state.channels[0].name +} + +// seedFakeChannels() diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..84c997f --- /dev/null +++ b/src/theme.ts @@ -0,0 +1,4 @@ +import resolveConfig from 'tailwindcss/resolveConfig' +import tailwindConfig from '../tailwind.config.js' + +export const { theme } = resolveConfig(tailwindConfig) diff --git a/src/ui/Canvas.tsx b/src/ui/Canvas.tsx new file mode 100644 index 0000000..d4427b5 --- /dev/null +++ b/src/ui/Canvas.tsx @@ -0,0 +1,34 @@ +import { Sigui, type Signal } from 'sigui' +import { cn } from '~/lib/cn.ts' +import { screen } from '~/src/screen.ts' + +export function Canvas({ width, height, class: className }: { + width: Signal + height: Signal + class?: string +}) { + using $ = Sigui() + + const canvas = as HTMLCanvasElement + + const info = $({ + pr: screen.$.pr, + width, + height, + }) + + $.fx(() => { + const { width, height, pr } = $.of(info) + $() + canvas.width = width * pr + canvas.height = height * pr + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + }) + + return canvas +} diff --git a/src/ui/Editor.tsx b/src/ui/Editor.tsx new file mode 100644 index 0000000..966499e --- /dev/null +++ b/src/ui/Editor.tsx @@ -0,0 +1,74 @@ +import { Input, Misc, Pane, Rect, View, type InputHandlers, type InputMouse, type Linecol, type WordWrapProcessor } from 'editor' +import { Sigui, type $, type Signal } from 'sigui' +import type { Source, Token } from '~/src/lang/tokenize.ts' + +export function Editor({ code, width, height, colorize, tokenize, wordWrapProcessor, inputHandlers }: { + code: Signal + width: Signal + height: Signal + colorize: (token: Token) => { fill: string, stroke: string } + tokenize: (source: Source) => Generator + wordWrapProcessor: WordWrapProcessor + inputHandlers: InputHandlers +}) { + using $ = Sigui() + + const misc = Misc() + const view = View({ width, height }) + + // initial pane + const pane = createPane({ + rect: $(Rect(), { x: 20, y: 20, w: 193, h: 200 }), + code, + }) + + const info = $({ + pane, + panes: new Set([pane]) + }) + + const input = Input({ + view, + pane: info.$.pane, + panes: info.$.panes, + }) + + function createPane({ rect, code }: { + rect: $ + code: Signal + }) { + return Pane({ + misc, + view, + rect, + code, + colorize, + tokenize, + wordWrapProcessor, + inputHandlers, + }) + } + + function addPane(pane: Pane) { + info.panes = new Set([...info.panes, pane]) + view.anim.ticks.add(pane.draw.draw) + } + + function removePane(pane: Pane) { + info.panes.delete(pane) + info.panes = new Set(info.panes) + view.anim.ticks.delete(pane.draw.draw) + } + + addPane(pane) + + return { + el: view.el, + info, + focus: input.focus, + view, + createPane, + addPane, + removePane + } +} diff --git a/src/ui/editor/buffer.test.ts b/src/ui/editor/buffer.test.ts new file mode 100644 index 0000000..d77953b --- /dev/null +++ b/src/ui/editor/buffer.test.ts @@ -0,0 +1,213 @@ +import { $ } from 'sigui' +import { tokenize } from '~/src/lang/tokenize.ts' +import { Buffer } from '~/src/ui/editor/buffer.ts' +import { Dims } from '~/src/ui/editor/dims.ts' +import { Rect } from '~/src/ui/editor/util/types.ts' + +function B(code: string, maxWidth: number) { + const info = $({ code, width: maxWidth, height: 300 }) + const rect = $(Rect(), { w: 100 }) + const dims = Dims({ rect }) + dims.info.charWidth = 100 / maxWidth + const b = Buffer({ dims, code: info.$.code, tokenize }) + b.info.maxColumns = maxWidth + return b +} + +describe('TextBuffer', () => { + describe('linesVisual', () => { + it('word wrap is applied', () => { + const b = B('', 10) + expect(b.info.linesVisual).toEqual([ + { text: '' } + ]) + }) + + it('one line if text below maxLineLength', () => { + const b = B('hello', 10) + expect(b.info.linesVisual).toEqual([ + { text: 'hello' } + ]) + }) + + it('handle newlines', () => { + const b = B('hello\nworld', 10) + expect(b.info.linesVisual).toEqual([ + { text: 'hello', br: true }, + { text: 'world' }, + ]) + }) + + it('splits to 2 lines if text exceeds maxLineLength', () => { + const b = B('hello world', 10) + expect(b.info.linesVisual).toEqual([ + { text: 'hello ' }, + { text: 'world' }, + ]) + }) + + it('splits to multiple lines if text exceeds maxLineLength twice', () => { + const b = B('hello world lorem ipsum', 10) + expect(b.info.linesVisual).toEqual([ + { text: 'hello ' }, + { text: 'world ' }, + { text: 'lorem ' }, + { text: 'ipsum' } + ]) + }) + + it('splits words', () => { + const b = B('helloworld loremipsum', 8) + expect(b.info.linesVisual).toEqual([ + { text: 'hellowor' }, + { text: 'ld ' }, + { text: 'loremips' }, + { text: 'um' }, + ]) + }) + + // it('splits words and lines', () => { + // const lines = wordWrapText(l('helloworld loremipsum'), 5) + // expect(lines).toEqual(['hello', 'world', 'lorem', 'ipsum']) + // }) + + // it('splits words and lines with multiple spaces', () => { + // const lines = wordWrapText(l('hello world lorem ipsum'), 5) + // expect(lines).toEqual(['hello', 'world', 'lorem', 'ipsum']) + // }) + + // it('splits words and lines with multiple spaces 2', () => { + // const lines = wordWrapText(l('hello world lorem ipsum'), 5) + // expect(lines).toEqual(['hello', 'world', 'lorem', 'ipsum']) + // }) + + // it('handle multiple spaces and words exceeding line length', () => { + // const lines = wordWrapText(l('hello world loremipsum'), 5) + // expect(lines).toEqual(['hello', 'world', 'lorem', 'ipsum']) + // }) + + it('handle spaces at beginning of lines', () => { + const b = B(' hello world loremipsum', 7) + expect(b.info.linesVisual).toEqual([ + { text: ' ' }, + { text: 'hello ' }, + { text: 'world ' }, + { text: 'loremip' }, + { text: 'sum' }, + ]) + }) + + it('handle spaces at beginning of lines 2', () => { + const b = B(' hello world loremipsum', 5) + expect(b.info.linesVisual).toEqual([ + { text: ' ' }, + { text: 'hello ' }, + { text: ' ' }, + { text: 'world ' }, + { text: 'lorem' }, + { text: 'ipsum' }, + ]) + }) + }) + + describe('visualPointToIndex / indexToVisualPoint / indexToLogicalPoint', () => { + it('works', () => { + const b = B(`\ +hello world +lorem ipsum +`, 10) + { + const i = b.visualPointToIndex({ x: 0, y: 4 }) + expect(i).toEqual(24) + { + const p = b.indexToVisualPoint(24) + expect(p).toEqual({ x: 0, y: 4 }) + } + { + const p = b.indexToLogicalPoint(24) + expect(p).toEqual({ x: 0, y: 2 }) + } + } + { + const i = b.visualPointToIndex({ x: 0, y: 1 }) + expect(i).toEqual(6) + { + const p = b.indexToVisualPoint(6) + expect(p).toEqual({ x: 0, y: 1 }) + } + { + const p = b.indexToLogicalPoint(6) + expect(p).toEqual({ x: 6, y: 0 }) + } + } + { + const i = b.visualPointToIndex({ x: 3, y: 3 }) + expect(i).toEqual(21) + { + const p = b.indexToVisualPoint(21) + expect(p).toEqual({ x: 3, y: 3 }) + } + { + const p = b.indexToLogicalPoint(21) + expect(p).toEqual({ x: 9, y: 1 }) + } + } + { + const i = b.visualPointToIndex({ x: 3, y: 2 }) + expect(i).toEqual(15) + { + const p = b.indexToVisualPoint(15) + expect(p).toEqual({ x: 3, y: 2 }) + } + { + const p = b.indexToLogicalPoint(15) + expect(p).toEqual({ x: 3, y: 1 }) + } + } + { + const i = b.visualPointToIndex({ x: 3, y: 1 }) + expect(i).toEqual(9) + { + const p = b.indexToVisualPoint(9) + expect(p).toEqual({ x: 3, y: 1 }) + } + { + const p = b.indexToLogicalPoint(9) + expect(p).toEqual({ x: 9, y: 0 }) + } + } + { + const i = b.visualPointToIndex({ x: 3, y: 0 }) + expect(i).toEqual(3) + { + const p = b.indexToVisualPoint(3) + expect(p).toEqual({ x: 3, y: 0 }) + } + { + const p = b.indexToLogicalPoint(3) + expect(p).toEqual({ x: 3, y: 0 }) + } + } + }) + }) + + describe('indexToVisualPoint', () => { + it('splits to multiple lines if text exceeds maxLineLength twice', () => { + const b = B(`\ +aaa +bbbbb ccccccc`, 10) + { + const i = b.visualPointToIndex({ x: 7, y: 2 }) + expect(i).toEqual(17) + const j = b.indexToVisualPoint(17) + expect(j).toEqual({ x: 7, y: 2 }) + } + { + const i = b.visualPointToIndex({ x: 7, y: 1 }) + expect(i).toEqual(9) + const j = b.indexToVisualPoint(9) + expect(j).toEqual({ x: 5, y: 1 }) + } + }) + }) +}) diff --git a/src/ui/editor/buffer.ts b/src/ui/editor/buffer.ts new file mode 100644 index 0000000..09dc3e0 --- /dev/null +++ b/src/ui/editor/buffer.ts @@ -0,0 +1,299 @@ +import { Sigui, type Signal } from 'sigui' +import type { Source, Token } from '~/src/lang/tokenize.ts' +// KEEP: imports are not from 'editor' because of bun test +// loading singletons that access the window object +import { type Dims } from '~/src/ui/editor/dims.ts' +import { parseWords } from '~/src/ui/editor/util/parse-words.ts' +import { TOKEN, WORD } from '~/src/ui/editor/util/regexp.ts' +import { linecolToPoint, type Linecol, type Point } from '~/src/ui/editor/util/types.ts' + +export interface Line { + text: string + br?: true +} + +export interface WordWrapProcessor { + pre(s: string): string + post(s: string): string +} +export type Buffer = ReturnType + +function identity(x: any) { return x } + +export function Buffer({ dims, code, tokenize, wordWrapProcessor = { pre: identity, post: identity } }: { + dims: Dims + code: Signal + tokenize: (source: Source) => Generator + wordWrapProcessor?: WordWrapProcessor +}) { + using $ = Sigui() + + const info = $({ + maxColumns: dims.info.$.pageWidth, + wordWrapEnabled: true, + code, + lines: [] as string[], + get length() { + return info.code.length + }, + get codeVisual() { + return info.linesVisual.map(line => line.text + (line.br ? '\r' : '')).join('\n') + }, + get source() { + return { code: info.codeVisual } + }, + get tokens() { + return [...tokenize(info.source)] + }, + get words() { + return parseWords(WORD, info.code) + }, + get linesVisual(): Line[] { + const { code, maxColumns, wordWrapEnabled } = info + $() + return wordWrapEnabled + ? wordWrap() + : code.split('\n').map(text => ({ text, br: true })) + } + }) + + // split code into lines + $.fx(() => { + const { code } = info + $() + info.lines = code.split('\n') + }) + + // join lines into code + $.fx(() => { + const { lines } = info + $() + info.code = lines.join('\n') + }) + + function wordWrap(): Line[] { + let { code, maxColumns } = info + + const wrapped: Line[] = [] + let line = '' + let word = '' + let x = 0 + + code = wordWrapProcessor.pre(code) + + function push() { + const joined = line + word + if (joined.length > maxColumns) { + wrapped.push({ text: line }) + if (word.length) wrapped.push({ text: word }) + } + else { + wrapped.push({ text: joined }) + } + word = '' + line = '' + x = 0 + } + + for (let i = 0; i < code.length; i++) { + const c = code[i] + if (c === '\n') { + wrapped.push({ text: line + word, br: true }) + word = '' + line = '' + x = 0 + } + else if (c === ' ') { + if (x >= maxColumns) { + push() + word = c + } + else { + line += word + c + word = '' + } + } + else { + if (word.length >= maxColumns) { + wrapped.push({ text: word }) + word = '' + x = 0 + } + word += c + if (line.length && (line + word).length >= maxColumns) { + wrapped.push({ text: line }) + line = '' + x = 0 + } + } + x++ + } + + if (line.length || word.length || word.length === code.length) { + push() + } + + return wrapped.map(line => { + line.text = wordWrapProcessor.post(line.text) + return line + }) + } + + function indexToLogicalPoint(index: number): Point { + const { lines } = info + + let currentIndex = 0 + + for (let y = 0; y < lines.length; y++) { + const lineLength = lines[y].length + 1 // +1 for the newline character + + if (currentIndex + lineLength > index) { + // The point is on this line + const x = index - currentIndex + return { x, y } + } + + currentIndex += lineLength + } + + // If we've reached here, the index is at the very end of the text + return { + x: lines.at(-1).length, + y: lines.length - 1 + } + } + + function indexToVisualPoint(index: number): Point { + const { linesVisual } = info + + let idx = 0 + + for (let y = 0; y < linesVisual.length; y++) { + const line = linesVisual[y] + const lineLength = line.text.length + (line.br ? 1 : 0) + + if (idx + lineLength > index) { + const x = index - idx + return { x, y } + } + + idx += lineLength + } + + // if we've gone through all lines and haven't found the position, + // return the end of the last line + const line = linesVisual.at(-1) + return { + x: line.br ? 0 : line.text.length, + y: linesVisual.length - (line.br ? 0 : 1) + } + } + + function logicalPointToIndex(point: Point): number { + const { lines } = info + let { x, y } = point + + if (y > lines.length - 1) { + y = lines.length - 1 + x = lines[y].length + } + else { + x = Math.min(x, lines[y]?.length ?? 0) + } + + const before = lines + .slice(0, y) + .reduce((sum, line) => + sum + line.length + 1, + 0 + ) + + const index = before + x + + return index + } + + function visualPointToIndex(point: Point): number { + const { linesVisual } = info + let { x, y } = point + + if (y >= linesVisual.length) return info.code.length + + let line = linesVisual[y] + x = Math.min( + x, + line.text.length + - (line.br || y === linesVisual.length - 1 + ? 0 + : 1 + ) + ) + + let index = 0 + + // sum up the lengths of all previous lines + for (let i = 0; i < y; i++) { + const line = linesVisual[i] + index += line.text.length + (line.br ? 1 : 0) + } + + // add the x position of the current line + index += Math.min(x, line.text.length) + + return index + } + + function visualPointToLogicalPoint(point: Point): Point { + const index = visualPointToIndex(point) + return indexToLogicalPoint(index) + } + + function logicalPointToVisualPoint(point: Point): Point { + const index = logicalPointToIndex(point) + return indexToVisualPoint(index) + } + + function wordUnderVisualPoint(point: Point): RegExpExecArray | undefined { + const { x, y } = point + const words = parseWords(TOKEN, info.linesVisual[y].text) + for (let i = 0, word: RegExpExecArray, next: any; i < words.length; i++) { + word = words[i] + next = i < words.length - 1 ? words[i + 1] : { index: Infinity } + if (x >= word.index && x < next.index) { + return word + } + } + } + + function wordUnderIndex(index: number): RegExpExecArray | undefined { + const words = parseWords(TOKEN, info.code) + for (let i = 0, word: RegExpExecArray, next: any; i < words.length; i++) { + word = words[i] + next = i < words.length - 1 ? words[i + 1] : { index: Infinity } + if (index >= word.index && index < next.index) { + return word + } + } + } + + function wordUnderLinecol(linecol: Linecol): RegExpExecArray | undefined { + return wordUnderIndex(visualPointToIndex(linecolToPoint(linecol))) + } + + return $({ + info, + code, + length: info.$.length, + lines: info.$.lines, + linesVisual: info.$.linesVisual, + indexToLogicalPoint, + indexToVisualPoint, + logicalPointToIndex, + visualPointToIndex, + visualPointToLogicalPoint, + logicalPointToVisualPoint, + wordUnderVisualPoint, + wordUnderIndex, + wordUnderLinecol, + }) +} diff --git a/src/ui/editor/caret.ts b/src/ui/editor/caret.ts new file mode 100644 index 0000000..9d8bf43 --- /dev/null +++ b/src/ui/editor/caret.ts @@ -0,0 +1,154 @@ +import { beginOfLine, Point, type Buffer, type PaneInfo } from 'editor' +import { Sigui } from 'sigui' +import { assign, clamp } from 'utils' + +export type Caret = ReturnType + +export function Caret({ paneInfo, buffer }: { + paneInfo: PaneInfo, + buffer: Buffer, +}) { + using $ = Sigui() + + const info = $({ + x: 0, + y: 0, + index: 0, + visual: $(Point()), + visualXIntent: 0, + blinkReset: 0, + isBlink: false, + isVisible: false, + }) + + const caret = info + + // set caret points from index + $.fx(() => { + const { index } = caret + $() + caret.blinkReset++ + assign(caret.visual, buffer.indexToVisualPoint(index)) + assign(caret, buffer.indexToLogicalPoint(index)) + }) + + // set index from visual point + $.fx(() => { + const { x, y } = caret.visual + $() + caret.index = buffer.visualPointToIndex({ x, y }) + }) + + // set index from logical point + $.fx(() => { + const { x, y } = caret + $() + caret.index = buffer.logicalPointToIndex({ x, y }) + }) + + function doBackspace() { + if (caret.index > 0) { + const { code, lines } = buffer + let chars = 1 + if (beginOfLine(lines[caret.y]) === caret.x && caret.x > 0) { + chars = (2 - (caret.x % 2)) + } + buffer.code = code.slice(0, caret.index - chars) + code.slice(caret.index) + $.flush() + moveByChars(-chars) + } + } + + function doDelete() { + const { code } = buffer + const { index } = caret + if (index < code.length) { + buffer.code = code.slice(0, index) + code.slice(index + 1) + } + } + + function moveHome() { + const { linesVisual } = buffer.info + const bx = beginOfLine(linesVisual[caret.visual.y]?.text ?? '') + const vx = bx === caret.visual.x ? 0 : bx + caret.index = buffer.visualPointToIndex({ + x: vx, + y: caret.visual.y + }) + } + + function moveEnd() { + caret.index = buffer.visualPointToIndex({ + x: buffer.info.linesVisual[caret.visual.y]?.text.length ?? 0, + y: caret.visual.y + }) + } + + function moveUpDown(dy: number) { + const { linesVisual } = buffer.info + let newX = caret.visualXIntent + let newY = caret.visual.y + dy + if (newY < 0) { + if (caret.visual.y === 0) newX = 0 + newY = 0 + } + const lastY = linesVisual.length - 1 + if (newY > lastY) { + if (caret.visual.y === lastY) { + newX = linesVisual[lastY].text.length + } + newY = lastY + } + if (newY >= 0 && newY <= linesVisual.length) { + caret.index = buffer.visualPointToIndex({ + x: newX, + y: newY + }) + } + } + + function moveByChars(chars: number) { + caret.index = clamp(0, buffer.length, caret.index + chars) + } + + function moveByWord(dir: number) { + const { code, words } = buffer.info + let index = dir > 0 ? code.length : 0 + for (let i = dir > 0 ? 0 : words.length - 1; dir > 0 ? i < words.length : i >= 0; dir > 0 ? i++ : i--) { + const word = words[i] + if ((dir > 0 && word.index > caret.index) || (dir < 0 && word.index < caret.index)) { + index = word.index + break + } + } + caret.index = index + } + + function insert(key: string) { + const { code } = buffer + buffer.code = + code.slice(0, caret.index) + + key + + code.slice(caret.index) + $.flush() + } + + return $({ + info, + x: caret.$.x, + y: caret.$.y, + line: caret.visual.$.y, + col: caret.visual.$.x, + index: caret.$.index, + visual: caret.$.visual, + visualXIntent: caret.$.visualXIntent, + doBackspace, + doDelete, + moveHome, + moveEnd, + moveUpDown, + moveByChars, + moveByWord, + insert, + }) +} diff --git a/src/ui/editor/dims.ts b/src/ui/editor/dims.ts new file mode 100644 index 0000000..aaeb75e --- /dev/null +++ b/src/ui/editor/dims.ts @@ -0,0 +1,34 @@ +import { Point, type Rect } from '~/src/ui/editor/util/types.ts' +import { Sigui, type $ } from 'sigui' +import { isMobile } from 'utils' + +export type Dims = ReturnType + +export function Dims({ rect }: { + rect: $ +}) { + using $ = Sigui() + + const info = $({ + rect, + + caretWidth: 1.5, + + charWidth: 1, + charHeight: 1, + + lineHeight: 19, + + pageHeight: 1, + pageWidth: 1, + + innerSize: $(Point()), + + scrollX: 0, + scrollY: 0, + scrollbarHandleSize: isMobile() ? 30 : 10, + scrollbarViewSize: 5, + }) + + return { info } +} diff --git a/src/ui/editor/draw.ts b/src/ui/editor/draw.ts new file mode 100644 index 0000000..ecd8769 --- /dev/null +++ b/src/ui/editor/draw.ts @@ -0,0 +1,438 @@ +import { Point, Widgets, type Buffer, type Caret, type Dims, type Linecol, type PaneInfo, type Selection, type View } from 'editor' +import { Matrix, Rect } from 'gfx' +import { Sigui } from 'sigui' +import { assign, clamp, drawText, randomHex } from 'utils' +import type { Token } from '~/src/lang/tokenize.ts' +import { screen } from '~/src/screen.ts' +import { theme } from '~/src/theme.ts' + +interface TokenDrawInfo { + point: Point + fill: string + stroke: string +} + +export type Draw = ReturnType + +export function Draw({ paneInfo, view, selection, caret, dims, buffer, colorize }: { + paneInfo: PaneInfo + view: View + selection: Selection + caret: Caret + dims: Dims + buffer: Buffer + colorize: (token: Token) => { fill: string, stroke: string } +}) { + using $ = Sigui() + + const { c } = view + const { rect } = dims.info.$ + + const info = $({ + c: null as null | CanvasRenderingContext2D, + pr: screen.$.pr, + rect, + + triggerUpdateTokenDrawInfo: 0, + + // scrollbars + showScrollbars: false, + scrollbarColor: '#aaa', + scrollbarY: $({ + get coeff() { + return info.rect.h / dims.info.innerSize.y + }, + get isVisible() { + return this.coeff < 1 + }, + get size() { + return dims.info.scrollbarViewSize * ((paneInfo.isHoveringScrollbarY || paneInfo.isDraggingScrollbarY) ? 2 : 1) + }, + get length() { + return info.rect.h * this.coeff + }, + get offset() { + return info.rect.y + (-dims.info.scrollY / dims.info.innerSize.y) * info.rect.h + }, + get pos() { + return info.rect.x + info.rect.w - this.size + }, + get color() { + return info.scrollbarColor + ( + paneInfo.isDraggingScrollbarY ? 'f' + : paneInfo.isHoveringScrollbarY ? '7' + : '5' + ) + } + }), + scrollbarX: $({ + get coeff() { + return info.rect.w / dims.info.innerSize.x + }, + get isVisible() { + return this.coeff < 1 + }, + get size() { + return dims.info.scrollbarViewSize * ((paneInfo.isHoveringScrollbarX || paneInfo.isDraggingScrollbarX) ? 2 : 1) + }, + get length() { + return info.rect.w * this.coeff + }, + get offset() { + return info.rect.x + (-dims.info.scrollX / dims.info.innerSize.x) * info.rect.w + }, + get pos() { + return info.rect.y + info.rect.h - this.size + }, + get color() { + return info.scrollbarColor + ( + paneInfo.isDraggingScrollbarX ? 'f' + : paneInfo.isHoveringScrollbarX ? '7' + : '5' + ) + } + }) + }) + + const widgets = Widgets({ c }) + const webglView = Rect(info.rect.$.x, info.rect.$.y, info.rect.$.w, info.rect.$.h) + const webglMatrix = Matrix() + const webgl = view.gfx.createContext(webglView, webglMatrix) + const webglShapes = webgl.createShapes() + webgl.sketch.scene.add(webglShapes) + view.anim.ticks.add(webgl.meshes.draw) + $.fx(() => { + const { scrollX, scrollY } = dims.info + $() + webglMatrix.e = scrollX + webglMatrix.f = scrollY + }) + + // drawing info + const tokenDrawInfo = new WeakMap() + let caretViewPoint = $(Point()) + let extraHeights: number[] = [] + const textPadding = 5 + + function viewPointFromLinecol({ line, col }: Linecol): Point { + const { charWidth, lineHeight } = dims.info + + const p = Point() + p.x = col * charWidth + + let top = 0 + for (let y = 0; y <= line; y++) { + let l = widgets.lines.get(y) + if (l) top += l.deco + if (y === line) p.y = top + top += lineHeight + if (l?.subs) top += l.subs + 2 + } + + p.x = Math.floor(p.x + textPadding) + p.y = Math.floor(p.y + textPadding) + + return p + } + + function linecolFromViewPoint({ x, y }: Point): Linecol { + const { charWidth, lineHeight } = dims.info + const { linesVisual } = buffer.info + + y -= textPadding + x -= textPadding + + let top = 0 + out: { + for (let i = 0; i <= linesVisual.length; i++) { + if (y < top) { + y = i - 1 + break out + } + + let l = widgets.lines.get(i) + if (l) { + top += l.deco + if (l.subs) top += l.subs + 2 + } + top += lineHeight + } + y = linesVisual.length - 1 + } + + const line = clamp(0, linesVisual.length - 1, y) + const col = clamp(0, linesVisual[y]?.text.length ?? 0, Math.round((x - 3) / charWidth)) + + return { line, col } + } + + // update token draw info + $.fx(() => { + const { triggerUpdateTokenDrawInfo } = info + const { tokens, linesVisual } = buffer.info + const { charWidth, lineHeight } = dims.info + + $() + + widgets.update() + + const lastVisibleLine = linesVisual.length + let eh = 0 + extraHeights = Array.from({ length: lastVisibleLine }, (_, y) => { + let curr = eh + const line = linesVisual[y] + if (!line.text.trim().length) eh += lineHeight + else eh = 0 + return curr + }) + + widgets.deco.forEach(w => { + const b = w.bounds + const p = viewPointFromLinecol(b) + w.rect.x = p.x + w.rect.y = p.y - widgets.heights.deco - extraHeights[b.line] + w.rect.w = ((b.right - b.col) * charWidth) | 0 + w.rect.h = widgets.heights.deco + extraHeights[b.line] - .5 + }) + + widgets.subs.forEach(w => { + const b = w.bounds + const p = viewPointFromLinecol(b) + w.rect.x = p.x + w.rect.y = p.y + lineHeight - 1 + w.rect.w = ((b.right - b.col) * charWidth) | 0 + w.rect.h = widgets.heights.subs + }) + + widgets.mark.forEach(w => { + const b = w.bounds + const p = viewPointFromLinecol(b) + w.rect.x = p.x - 1 + w.rect.y = p.y - 1 + w.rect.w = (((b.right - b.col) * charWidth) | 0) + 2.75 + w.rect.h = (lineHeight) - .5 + }) + + tokens.forEach(token => { + const point = viewPointFromLinecol(token) + point.y += 1 + const { fill, stroke } = colorize(token) + tokenDrawInfo.set(token, { + point, + fill, + stroke, + }) + }) + }) + + // update caret view point + $.fx(() => { + const { tokens } = buffer.info + const { x, y } = caret.visual + $() + // NOTE: bugfix while toggling block comments caret wasn't updated. + // there must be a real solution to this but for now this works. + assign(caretViewPoint, viewPointFromLinecol({ line: y, col: x })) + queueMicrotask(() => { + assign(caretViewPoint, viewPointFromLinecol({ line: y, col: x })) + }) + }) + + // wait for fonts to load + document.fonts.ready.then($.fn(() => { + info.c = c + + // measure char width and height + const metrics = c.measureText('M') + dims.info.charWidth = metrics.width + dims.info.charHeight = Math.ceil(metrics.fontBoundingBoxDescent - metrics.fontBoundingBoxAscent) + })) + + // update inner size + $.fx(() => { + const { w, h } = info.rect + const { linesVisual } = buffer.info + const { charWidth, lineHeight, innerSize } = dims.info + $() + + let x = 0 + + for (const line of linesVisual) { + x = Math.max( + x, + line.text.length * charWidth + + textPadding * 2 + ) + } + + innerSize.x = Math.max( + w, + x + + (info.scrollbarY.isVisible ? info.scrollbarY.size * 3 : 0) + ) + + innerSize.y = Math.max( + h, + viewPointFromLinecol({ + line: linesVisual.length, col: 0 + }).y + + textPadding * 2 + + (info.scrollbarX.isVisible ? info.scrollbarX.size * 2 : 0) + ) + }) + + // update scroll + $.fx(() => { + const { rect: { w, h }, scrollX, scrollY, innerSize } = dims.info + const { x, y } = innerSize + $() + dims.info.scrollX = clamp(-(x - w), 0, scrollX) + dims.info.scrollY = clamp(-(y - h), 0, scrollY) + }) + + // update show scrollbars when hovering pane + $.fx(() => { + const { isHovering } = paneInfo + $() + if (isHovering) { + info.showScrollbars = true + return () => info.showScrollbars = false + } + }) + + // keep caret in view by adjusting scroll when caret is moving + $.fx(() => { + const { x, y } = caretViewPoint + const { w, h } = info.rect + const { charWidth, charHeight, lineHeight } = dims.info + $() + const { scrollX, scrollY } = dims.info + + const padY = lineHeight + 1 + + if (y > -scrollY + h - charHeight - padY) dims.info.scrollY = -(y - h + charHeight + padY) + if (y < -scrollY + padY) dims.info.scrollY = -y + padY + + if (x > -scrollX + w - charWidth) dims.info.scrollX = -(x - w + charWidth) + if (x < -scrollX) dims.info.scrollX = -x + }) + + // update page size + $.fx(() => { + const { w, h } = info.rect + const { charWidth, lineHeight } = dims.info + $() + dims.info.pageWidth = Math.floor((w - textPadding * 2 - charWidth * 1.5) / charWidth) + dims.info.pageHeight = Math.floor((h - textPadding * 2) / lineHeight) + }) + + // trigger draw + $.fx(() => { + const { c, pr, rect: { x, y, w, h }, showScrollbars } = $.of(info) + const { + isHovering, + isHoveringScrollbarX, isDraggingScrollbarX, + isHoveringScrollbarY, isDraggingScrollbarY, + } = paneInfo + const { code } = buffer.info + const { caretWidth, charWidth, charHeight, lineHeight, scrollX, scrollY } = dims.info + const { isVisible: isCaretVisible } = caret.info + const { x: cx, y: cy } = caret.visual + const { start: { line: sl, col: sc }, end: { line: el, col: ec } } = selection.info + $() + view.anim.info.epoch++ + }) + + const color = randomHex(3, '2', '5') + + function drawClear() { + const { x, y, w, h } = info.rect + c.beginPath() + c.rect(x, y, w, h) + c.clip() + c.clearRect(x, y, w + 1, h + 1) + + // TODO: temp remove this + c.translate(x, y) + c.fillStyle = '#' + color + c.fillRect(0, 0, w, h) + + c.translate(dims.info.scrollX, dims.info.scrollY) + } + + function drawActiveLine() { + c.fillStyle = '#fff1' + c.fillRect(0, caretViewPoint.y, info.rect.w, dims.info.lineHeight - 2) + } + + function drawSelection() { + if (!selection.isActive) return + const { lineHeight } = dims.info + const { start, end } = selection.sorted + const brPadding = 5 + c.fillStyle = theme.colors.blue[800] + for (let line = start.line; line <= end.line; line++) { + const c1 = line === start.line ? start.col : 0 + const c2 = line === end.line ? end.col : buffer.linesVisual[line].text.length + const p1 = viewPointFromLinecol({ line, col: c1 }) + const p2 = viewPointFromLinecol({ line, col: c2 }) + c.fillRect(p1.x, p1.y, p2.x - p1.x + (line === end.line ? 0 : brPadding), lineHeight) + } + } + + function drawCode() { + buffer.info.tokens.forEach(token => { + const d = tokenDrawInfo.get(token) + if (d) drawText(c, d.point, token.text, d.fill, .025, d.stroke) + }) + } + + function drawCaret() { + if (!caret.info.isVisible) return + const { caretWidth: w, charHeight: h } = dims.info + const { x, y } = caretViewPoint + c.fillStyle = '#fff' + c.fillRect(x, y + .5, w, h) + } + + function drawScrollbars() { + if (!info.showScrollbars) return + + const { scrollbarX, scrollbarY } = info + + // draw vertical scrollbar + if (scrollbarY.isVisible) { + c.fillStyle = info.scrollbarY.color + const { pos: x, offset: y, size: w, length: h } = scrollbarY + c.fillRect(x, y, w, h) + } + + // draw horizontal scrollbar + if (scrollbarX.isVisible) { + c.fillStyle = info.scrollbarX.color + const { offset: x, pos: y, length: w, size: h } = scrollbarX + c.fillRect(x, y, w, h) + } + } + + function draw() { + c.save() + drawClear() + drawActiveLine() + drawSelection() + widgets.draw() + drawCode() + drawCaret() + c.restore() + drawScrollbars() + } + + return { + info, + draw, + webgl, + shapes: webglShapes, + widgets, + linecolFromViewPoint + } +} diff --git a/src/ui/editor/history.ts b/src/ui/editor/history.ts new file mode 100644 index 0000000..89ee71c --- /dev/null +++ b/src/ui/editor/history.ts @@ -0,0 +1,125 @@ +import { Buffer, Caret, Selection } from 'editor' +import { Sigui } from 'sigui' +import { assign, debounce } from 'utils' + +const DEBUG = false + +interface Snap { + buffer: { + code: string + } + caret: { + index: number + visualXIntent: number + } + selection: { + startIndex: number + endIndex: number + } +} + +export type History = ReturnType + +export function History({ selection, buffer, caret }: { + selection: Selection + buffer: Buffer + caret: Caret +}) { + using $ = Sigui() + + const info = $({ + needle: 0, + snaps: [Snap()], + get current() { + return info.snaps[info.needle] + } + }) + + function Snap(): Snap { + return { + buffer: { + code: buffer.code, + }, + caret: { + index: caret.index, + visualXIntent: caret.visualXIntent, + }, + selection: { + startIndex: selection.startIndex, + endIndex: selection.endIndex, + } + } + } + + function save() { + const snap = Snap() + + // merge + if (info.current.buffer.code === snap.buffer.code) { + DEBUG && console.debug('[hist] merge:', info.needle + 1, '/', info.snaps.length) + assign(info.current.caret, snap.caret) + assign(info.current.selection, snap.selection) + return + } + + // discard rest + if (info.needle < info.snaps.length - 1) { + info.snaps = info.snaps.slice(0, info.needle + 1) + } + + // save + info.snaps.push(snap) + info.needle++ + DEBUG && console.debug('[hist] save:', info.needle + 1, '/', info.snaps.length) + } + + const saveDebounced = debounce(300, save, { first: true, last: true }) + + function restore() { + const snap = info.snaps[info.needle] + assign(buffer, snap.buffer) + assign(caret, snap.caret) + assign(selection, snap.selection) + } + + function withHistory(fn: () => void) { + $.flush() + save() + fn() + $.flush() + save() + } + + function withHistoryDebounced(fn: () => void) { + $.flush() + saveDebounced() + fn() + $.flush() + saveDebounced() + } + + function undo() { + if (info.needle > 0) { + info.needle-- + restore() + } + DEBUG && console.debug('[hist] undo:', info.needle + 1, '/', info.snaps.length) + } + + function redo() { + if (info.needle < info.snaps.length - 1) { + info.needle++ + restore() + } + DEBUG && console.debug('[hist] redo:', info.needle + 1, '/', info.snaps.length) + } + + return { + save, + saveDebounced, + withHistory, + withHistoryDebounced, + undo, + redo + } +} diff --git a/src/ui/editor/index.ts b/src/ui/editor/index.ts new file mode 100644 index 0000000..a0c8ede --- /dev/null +++ b/src/ui/editor/index.ts @@ -0,0 +1,14 @@ +export * from './buffer.ts' +export * from './caret.ts' +export * from './dims.ts' +export * from './draw.ts' +export * from './history.ts' +export * from './input.ts' +export * from './kbd.tsx' +export * from './misc.ts' +export * from './mouse.ts' +export * from './pane.ts' +export * from './selection.ts' +export * from './util/index.ts' +export * from './view.tsx' +export * from './widget.ts' diff --git a/src/ui/editor/input.ts b/src/ui/editor/input.ts new file mode 100644 index 0000000..0ffb7f5 --- /dev/null +++ b/src/ui/editor/input.ts @@ -0,0 +1,288 @@ +import { isPointInRect, type Pane, type Point, type View } from 'editor' +import { Sigui, type Signal } from 'sigui' +import { assign, dom, isMobile, MouseButtons } from 'utils' + +export interface InputMouse extends Point { + isDown: boolean + buttons: number + ctrl: boolean +} + +export interface InputHandlers { + onKeyDown: (pane: Pane) => boolean | void + onKeyUp: (pane: Pane) => boolean | void + onMouseWheel: (pane: Pane) => boolean | void + onMouseDown: (pane: Pane) => boolean | void + onMouseUp: (pane: Pane) => boolean | void + onMouseMove: (pane: Pane) => boolean | void +} + +export type Input = ReturnType + +export function Input({ view, pane, panes }: { + view: View + pane: Signal + panes: Signal> +}) { + using $ = Sigui() + + const { textarea, el } = view + + const info = $({ + pane, + panes, + hoveringPane: null as null | Pane, + }) + + // read keyboard input + $.fx(() => [ + // mobile keyboard + dom.on(textarea, 'input', $.fn((ev: Event) => { + const inputEvent = ev as InputEvent + if (inputEvent.data) { + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: inputEvent.data })) + textarea.value = '' + } + })), + + // desktop keyboard + dom.on(textarea, 'keydown', $.fn((ev: KeyboardEvent) => { + info.pane.kbd.handleKeyDown(ev) + })), + + dom.on(textarea, 'keyup', $.fn((ev: KeyboardEvent) => { + info.pane.kbd.handleKeyUp(ev) + })), + + // clipboard paste + dom.on(textarea, 'paste', $.fn((ev: ClipboardEvent) => { + ev.preventDefault() + const text = ev.clipboardData?.getData('text/plain') + const { history, selection, caret } = info.pane + const { withHistory } = history + if (text) { + withHistory(() => { + selection.reset() + caret.insert(text) + caret.index += text.length + $.flush() + selection.toCaret() + }) + } + })), + + // clipboard copy + dom.on(textarea, 'copy', $.fn((ev: ClipboardEvent) => { + ev.preventDefault() + const { selection } = info.pane + ev.clipboardData?.setData('text/plain', selection.text) + })), + + // clipboard cut + dom.on(textarea, 'cut', $.fn((ev: ClipboardEvent) => { + ev.preventDefault() + const { history, selection } = info.pane + const { withHistory } = history + ev.clipboardData?.setData('text/plain', selection.text) + withHistory(selection.deleteText) + })), + ]) + + // input mouse + const inputMouse: InputMouse = $({ + isDown: false, + buttons: 0, + ctrl: false, + x: 0, + y: 0, + } satisfies InputMouse) + + // update hovering info + $.fx(() => { + const { hoveringPane: pane } = info + $() + if (pane) { + pane.info.isHovering = true + return () => { + pane.info.isHovering = false + } + } + }) + + // unset hoveringPane when pointer leaves the window + $.fx(() => + dom.on(document, 'pointerleave', () => { + info.hoveringPane = null + }) + ) + + // update cursor + $.fx(() => { + const { hoveringPane: pane } = info + if (!pane) { + $() + view.info.cursor = 'default' + return + } + const { + isHovering, + isHoveringScrollbarX, + isHoveringScrollbarY, + isDraggingScrollbarX, + isDraggingScrollbarY, + } = pane.info + $() + view.info.cursor = (!isHovering && !info.hoveringPane) || + (isHoveringScrollbarX || isDraggingScrollbarX) || + (isHoveringScrollbarY || isDraggingScrollbarY) + ? 'default' + : 'text' + }) + + function updatePaneMouse(pane: Pane) { + pane.mouse.info.buttons = inputMouse.buttons + pane.mouse.info.ctrl = inputMouse.ctrl + assign(pane.mouse.info.actual, { + x: inputMouse.x - pane.dims.info.rect.x, + y: inputMouse.y - pane.dims.info.rect.y, + }) + assign(pane.mouse.info, { + x: inputMouse.x - pane.dims.info.rect.x - pane.dims.info.scrollX, + y: inputMouse.y - pane.dims.info.rect.y - pane.dims.info.scrollY, + }) + } + + function updateInputMouseFromEvent(ev: WheelEvent | PointerEvent | TouchEvent) { + inputMouse.ctrl = ev.ctrlKey + + if (ev instanceof TouchEvent) { + const touch = ev.touches[0] ?? { pageX: 0, pageY: 0 } + inputMouse.x = touch.pageX + inputMouse.y = touch.pageY + inputMouse.buttons = MouseButtons.Left + } + else { + inputMouse.x = ev.pageX + inputMouse.y = ev.pageY + inputMouse.buttons = ev.buttons + } + + const c = el.getBoundingClientRect() + inputMouse.x -= c.left + inputMouse.y -= c.top + + // mouse down had started inside a pane + // so we update only that pane even if outside of it + if (info.pane.mouse.info.isDown) { + info.hoveringPane = info.pane + updatePaneMouse(info.pane) + return + } + + // find the pane mouse is hovering + out: { + for (const pane of info.panes) { + if (isPointInRect(inputMouse, pane.dims.info.rect)) { + // found pane + info.hoveringPane = pane + updatePaneMouse(pane) + break out + } + } + // no pane found under input mouse, unset hoveringPane + info.hoveringPane = null + } + } + + $.fx(() => [ + dom.on(el, 'contextmenu', dom.prevent.stop), + + dom.on(el, 'wheel', $.fn((ev: WheelEvent) => { + updateInputMouseFromEvent(ev) + updatePaneMouse(info.pane) + + const { hoveringPane: pane } = info + if (!pane) { + return + } + + ev.preventDefault() + pane.mouse.info.wheel.x = ev.deltaX + pane.mouse.info.wheel.y = ev.deltaY + pane.mouse.handleWheel() + }), { passive: false }), + + dom.on(el, isMobile() ? 'touchstart' : 'pointerdown', $.fn((ev: PointerEvent | TouchEvent) => { + if (!isMobile()) ev.preventDefault() + updateInputMouseFromEvent(ev) + inputMouse.isDown = true + + const { pane, hoveringPane } = info + + // click outside panes, unset current pane focus + if (!hoveringPane) { + pane.info.isFocus = false + return + } + + pane.info.isFocus = false + info.pane = hoveringPane + info.pane.mouse.handleDown() + })), + + dom.on(window, isMobile() ? 'touchend' : 'pointerup', $.fn((ev: PointerEvent | TouchEvent) => { + ev.preventDefault() + updateInputMouseFromEvent(ev) + inputMouse.isDown = false + info.pane.mouse.info.ctrl = inputMouse.ctrl + info.pane.mouse.handleUp() + })), + + dom.on(window, isMobile() ? 'touchmove' : 'pointermove', $.fn((ev: PointerEvent | TouchEvent) => { + ev.preventDefault() + updateInputMouseFromEvent(ev) + info.pane.mouse.info.ctrl = inputMouse.ctrl + info.pane.mouse.handleMove() + })), + ]) + + // focus/blur + function focus() { + textarea.focus({ preventScroll: true }) + } + + function preventAndFocus(ev: Event) { + ev.preventDefault() + focus() + } + + function onFocus() { + info.pane.info.isFocus = true + } + + function onBlur() { + if (inputMouse.isDown) { + if (info.pane.info.isFocus) { + info.pane.caret.info.isVisible = false + return + } + } + info.pane.info.isFocus = false + } + + $.fx(() => [ + dom.on(textarea, 'focus', onFocus), + dom.on(textarea, 'blur', onBlur), + dom.on(window, 'blur', onBlur), + ]) + + $.fx(() => [ + dom.on(window, 'focus', () => focus()), + dom.on(el, 'pointerup', preventAndFocus), + isMobile() && dom.on(el, 'touchend', preventAndFocus), + ]) + + requestAnimationFrame(focus) + + return { focus } +} diff --git a/src/ui/editor/kbd.tsx b/src/ui/editor/kbd.tsx new file mode 100644 index 0000000..97fae09 --- /dev/null +++ b/src/ui/editor/kbd.tsx @@ -0,0 +1,502 @@ +import { beginOfLine, Buffer, escapeRegExp, findMatchingBrackets, linecolToPoint, pointToLinecol, type Caret, type Dims, type History, type Misc, type PaneInfo, type Selection } from 'editor' +import { Sigui } from 'sigui' +import { assign } from 'utils' + +const IGNORED_KEYS = 'cvxjrtn=+-' + +export function Kbd({ paneInfo, misc, dims, selection, buffer, caret, history }: { + paneInfo: PaneInfo + misc: Misc + dims: Dims + selection: Selection + buffer: Buffer + caret: Caret + history: History +}) { + using $ = Sigui() + + const { withHistory, withHistoryDebounced, undo, redo } = history + + const info = $({ + key: '', + ctrl: false, + alt: false, + shift: false, + }) + + function tabIndent(shift: boolean) { + let index: number = Infinity + let lns: string[] + let y: number + const { lines } = buffer + const text = lines[caret.y] + if (selection.isActive) { + const { start, end } = selection.sorted + const p1 = buffer.visualPointToLogicalPoint(linecolToPoint(start)) + const p2 = buffer.visualPointToLogicalPoint(linecolToPoint(end)) + const { y: y1 } = p1 + const { y: y2 } = p2 + for (let y = y1; y <= y2; y++) { + index = Math.min(index, beginOfLine(lines[y])) + } + y = y1 + lns = lines.slice(y1, y2 + 1) + } + else if (shift) { + index = beginOfLine(text) + y = caret.y + lns = [lines[y]] + } + else { + caret.insert(' ') + caret.index += 2 + return + } + + const dec = shift + if (dec && !index) return + + let diff = 0 + const tabSize = 2 + const tab = ' '.repeat(tabSize) + lns.forEach((text, i) => { + if (dec) { + diff = -tabSize + text = text.replace(new RegExp(`^(\t| {1,${tabSize}})`, 'gm'), '') + } + else { + diff = +tabSize + text = text.length === 0 ? tab : text.replace(/^[^\n]/gm, `${tab}$&`) + } + lines[y + i] = text + }) + + buffer.code = lines.join('\n') + + if (selection.isActive) { + // TODO + } + else { + caret.visual.x = Math.max(0, caret.visual.x + diff) + $.flush() + selection.reset() + } + } + + function toggleSingleComment() { + const comment = misc.info.commentSingle + const { startIndex, end, endIndex, forward } = selection.sorted + const { lines } = buffer + + const p1 = buffer.indexToLogicalPoint(startIndex) + const p2 = buffer.indexToLogicalPoint(endIndex) + + const y1 = p1.y + const y2 = p1.y === p2.y || end.col > 0 ? p2.y : p2.y - 1 + + if (y1 === y2) { + const line = lines[y1] + const begin = beginOfLine(line) + const spaces = line.slice(0, begin) + let rest = line.slice(begin) + if (rest.startsWith(comment)) { + const r = new RegExp(`^${escapeRegExp(comment)}${spaces.length % 2 === 0 ? ' ?' : ''}`, 'm') + const length = rest.length + rest = rest.replace(r, '') + const chars = length - rest.length + selection.startIndex -= chars + selection.endIndex -= chars + } + else { + const commented = (spaces.length > 0 && spaces.length % 2 !== 0 ? ' ' : '') + comment + ' ' + rest = commented + rest + selection.startIndex += commented.length + selection.endIndex += commented.length + } + lines[y1] = spaces + rest + } + else { + // determine if we should add or remove comments + const slice = lines.slice(y1, y2 + 1) + const shouldRemove = slice.every(l => l.trimStart().startsWith(comment)) + + // find leftmost indent + let indent = Infinity + for (let y = y1; y <= y2; y++) { + const begin = beginOfLine(lines[y]) + indent = Math.min(begin, indent) + } + + let diff = 0 + let firstDiff = 0 + let first = true + if (shouldRemove) { + for (let y = y1; y <= y2; y++) { + const line = lines[y] + const begin = beginOfLine(line) + const spaces = line.slice(0, begin) + let rest = line.slice(begin) + const r = new RegExp(`^${escapeRegExp(comment)}${spaces.length % 2 === 0 ? ' ?' : ''}`, 'm') + const length = rest.length + rest = rest.replace(r, '') + const chars = length - rest.length + if (first) { + firstDiff = chars + first = false + } + diff += chars + lines[y] = spaces + rest + } + if (forward) { + selection.startIndex -= firstDiff + selection.endIndex -= diff + } + else { + selection.startIndex -= diff + selection.endIndex -= firstDiff + } + } + else { + for (let y = y1; y <= y2; y++) { + const line = lines[y] + const spaces = line.slice(0, indent) + const rest = line.slice(indent) + const commented = comment + ' ' + if (first) { + firstDiff = commented.length + first = false + } + diff += commented.length + lines[y] = spaces + commented + rest + } + if (forward) { + selection.startIndex += firstDiff + selection.endIndex += diff + } + else { + selection.startIndex += diff + selection.endIndex += firstDiff + } + } + } + + buffer.lines = [...lines] + $.flush() + caret.index = selection.endIndex + } + + function toggleDoubleComment() { + const [c1, c2] = misc.info.commentDouble + const { startIndex, end, endIndex, forward } = selection.sorted + const { code } = buffer + + let before = code.slice(0, startIndex) + let middle = code.slice(startIndex, endIndex) + let after = code.slice(endIndex) + + if (before.trimEnd().endsWith(c1) && after.trimStart().startsWith(c2)) { + const l = new RegExp(`${escapeRegExp(c1)}\\s*$`, 'm') + const length = before.length + before = before.replace(l, '') + const removed = length - before.length + const r = new RegExp(`^\\s*${escapeRegExp(c2)}`, 'm') + after = after.replace(r, '') + buffer.code = `${before}${middle}${after}` + selection.startIndex -= removed + selection.endIndex -= removed + } + else { + buffer.code = `${before}${c1} ${middle} ${c2}${after}` + selection.startIndex += c1.length + 1 + selection.endIndex += c1.length + 1 + } + $.flush() + caret.index = selection.endIndex + } + + function toggleBlockComment() { + const match = findMatchingBrackets(buffer.code, caret.index) + if (match) { + const c = misc.info.commentSingle + const [b1, b2] = match + const b1p = b1 + 1 + const { code } = buffer + const before = code.slice(0, b1p) + const middle = code.slice(b1p, b2) + const after = code.slice(b2) + if (middle.startsWith(c)) { + buffer.code = `${before}${middle.slice(c.length)}${after}` + if (caret.index > b1) caret.index-- + } + else { + buffer.code = `${before}${c}${middle}${after}` + if (caret.index > b1) caret.index++ + } + } + } + + function duplicateSelection() { + const { length } = selection.text + caret.insert(selection.text) + caret.index += length + selection.info.startIndex += length + selection.info.endIndex += length + } + + function duplicateLine() { + const { index } = caret + caret.index = buffer.logicalPointToIndex({ x: 0, y: caret.y }) + $.flush() + const line = buffer.info.lines[caret.y] + '\n' + caret.insert(line) + caret.index = index + line.length + } + + function moveLines(dy: number) { + const { start, end } = selection.sorted + const p1 = buffer.visualPointToLogicalPoint(linecolToPoint(start)) + const p2 = buffer.visualPointToLogicalPoint(linecolToPoint(end)) + const { y: y1 } = p1 + const { y: y2 } = p2 + + if (y1 === 0 && dy < 0) return + if (y2 === buffer.lines.length - 1 && dy > 0) return + if (y1 + dy < 0) dy = -y1 + if (y2 + dy >= buffer.lines.length) dy = buffer.lines.length - y2 + if (!dy) return + + const slice = buffer.lines.splice(y1, y2 - y1 + (p2.x > 0 || !selection.isActive ? 1 : 0)) + buffer.lines.splice(y1 + dy, 0, ...slice) + buffer.lines = [...buffer.lines] + p1.y += dy + p2.y += dy + caret.y += dy + assign(start, pointToLinecol(buffer.logicalPointToVisualPoint(p1))) + assign(end, pointToLinecol(buffer.logicalPointToVisualPoint(p2))) + } + + function updateInfoFromEvent(ev: KeyboardEvent) { + let key = ev.key + if (key === 'Enter') key = '\n' + info.key = key + info.ctrl = ev.ctrlKey || ev.metaKey + info.alt = ev.altKey + info.shift = ev.shiftKey + } + + function handleKeyDown(ev: KeyboardEvent): void { + if (!paneInfo.isFocus) return + if (ev.ctrlKey && IGNORED_KEYS.includes(ev.key.toLowerCase())) return + + ev.preventDefault() + updateInfoFromEvent(ev) + + let { key } = info + const { ctrl, alt, shift } = info + + caret.info.blinkReset++ + + if (kbd.onKeyDown()) return + + function withSelection(fn: () => void, force = false) { + if (!shift && selection.isActive) selection.reset() + if (force || (shift && !selection.isActive)) { + selection.reset() + } + fn() + $.flush() + if (force || shift) { + selection.toCaret() + } + else { + selection.reset() + } + } + + function withIntent(fn: () => void) { + fn() + $.flush() + caret.info.visualXIntent = caret.visual.x + } + + // TODO: + // - alt + arrow left/right (depends on ast) + if (key.length === 1) { + if (ctrl) { + // ctrl + a = select all + if (key === 'a') return selection.selectAll() + + // ctrl + / = toggle single comment line(s) + // ctrl + shift + / = toggle double comment line(s) + else if (key === '/' || key === '?') { + return withHistoryDebounced(() => + withIntent(() => { + if (shift) toggleDoubleComment() + else toggleSingleComment() + }) + ) + } + + // ctrl + ; = toggle block comment + else if (key === ';') { + return withHistoryDebounced(() => + withIntent(toggleBlockComment) + ) + } + + // ctrl + shift + d = + // with no selection: duplicate line + // with selection: duplicate selection + else if (shift && key === 'D') { + return withHistoryDebounced(() => { + if (selection.isActive) { + duplicateSelection() + } + else { + duplicateLine() + } + }) + } + + else if (key === 'z') return undo() + else if (key === 'y') return redo() + } + + // with selection: replace selection + if (selection.isActive) { + return withHistory(() => { + selection.deleteText() + handleKeyDown(ev) + }) + } + + // insert character + withHistoryDebounced(() => + withIntent(() => { + caret.insert(key) + caret.moveByChars(+1) + }) + ) + } + else { + // tab + if (key === 'Tab') { + withHistoryDebounced(() => + withIntent(() => + tabIndent(shift) + ) + ) + } + + // backspace + // with selection: delete selection + // with no selection: delete character before caret + else if (key === 'Backspace') { + withHistoryDebounced(() => { + if (selection.isActive) return selection.deleteText() + if (ctrl) { + withSelection(() => caret.moveByWord(-1), true) + return selection.deleteText() + } + withIntent(caret.doBackspace) + }) + } + + // delete + // with selection: delete selection + // with no selection: delete character after caret + else if (key === 'Delete') { + withHistoryDebounced(() => { + if (selection.isActive) return selection.deleteText() + if (ctrl) { + withSelection(() => caret.moveByWord(+1), true) + return selection.deleteText() + } + if (shift) { + // delete line + selection.selectLine() + $.flush() + selection.endIndex++ + $.flush() + selection.deleteText() + return + } + caret.doDelete() + }) + } + + // home + else if (key === 'Home') { + withSelection(() => withIntent(caret.moveHome)) + } + + // end + else if (key === 'End') { + withSelection(() => withIntent(caret.moveEnd)) + } + + // arrow up/down + else if ( + key === 'ArrowUp' || + key === 'ArrowDown' || + key === 'PageUp' || + key === 'PageDown' + ) { + let dy = 0 + if (key === 'ArrowUp') dy = -1 + else if (key === 'ArrowDown') dy = +1 + else if (key === 'PageUp') dy = -dims.info.pageHeight + else if (key === 'PageDown') dy = +dims.info.pageHeight + if (alt) { + // alt + arrow up/down: move lines/selection up/down + return withHistoryDebounced(() => + moveLines(dy) + ) + } + withSelection(() => + caret.moveUpDown(dy) + ) + } + + // arrow left + else if (key === 'ArrowLeft') { + withSelection(() => + withIntent(() => { + if (ctrl) caret.moveByWord(-1) + else caret.moveByChars(-1) + }) + ) + } + + // arrow right + else if (key === 'ArrowRight') { + withSelection(() => + withIntent(() => { + if (ctrl) caret.moveByWord(+1) + else caret.moveByChars(+1) + }) + ) + } + } + } + + function handleKeyUp(ev: KeyboardEvent): void { + ev.preventDefault() + updateInfoFromEvent(ev) + + if (kbd.onKeyUp()) return + } + + function onKeyDown(): boolean | void { } + function onKeyUp(): boolean | void { } + + const kbd = { + info, + handleKeyDown, + handleKeyUp, + onKeyDown, + onKeyUp, + } + + return kbd +} diff --git a/src/ui/editor/misc.ts b/src/ui/editor/misc.ts new file mode 100644 index 0000000..a019231 --- /dev/null +++ b/src/ui/editor/misc.ts @@ -0,0 +1,14 @@ +import { Sigui } from 'sigui' + +export type Misc = ReturnType + +export function Misc() { + using $ = Sigui() + + const info = $({ + commentSingle: ';', + commentDouble: ['[;', ']'], + }) + + return { info } +} diff --git a/src/ui/editor/mouse.ts b/src/ui/editor/mouse.ts new file mode 100644 index 0000000..229ebc3 --- /dev/null +++ b/src/ui/editor/mouse.ts @@ -0,0 +1,225 @@ +import { Linecol, Point, type Caret, type Dims, type Draw, type PaneInfo, type Selection } from 'editor' +import { Sigui } from 'sigui' +import { assign, MouseButtons } from 'utils' + +const CLICK_TIMEOUT = 350 + +export function Mouse({ paneInfo, dims, selection, caret, draw }: { + paneInfo: PaneInfo + dims: Dims + selection: Selection + caret: Caret + draw: Draw +}) { + using $ = Sigui() + + const info = $({ + isDown: false, + buttons: 0, + clickTimeout: -1 as any, + count: 0, + x: 0, + y: 0, + actual: $(Point()), + linecol: $(Linecol()), + wheel: $(Point()), + ctrl: false, + }) + + $.fx(() => { + const { x, y } = info + $() + assign(info.linecol, draw.linecolFromViewPoint(info)) + }) + + // blink caret + $.fx(() => { + const { isDown } = info + const { isFocus } = paneInfo + const { isBlink, blinkReset } = caret.info + $() + if (!isFocus || !isBlink) { + if (!isDown) { + caret.info.isVisible = false + selection.reset() + } + return + } + caret.info.isVisible = true + const caretIv = setInterval(() => { + caret.info.isVisible = !caret.info.isVisible + }, 500) + return () => { + caret.info.isVisible = true + clearInterval(caretIv) + } + }) + + let scrollbarPointStart = 0 + let scrollbarScrollStart = 0 + + $.fx(() => { + const { isDown, actual: { x, y } } = info + if (isDown) return + $() + if (x >= dims.info.rect.w - dims.info.scrollbarHandleSize && + !paneInfo.isDraggingScrollbarX && + draw.info.scrollbarY.isVisible + ) { + paneInfo.isHoveringScrollbarY = true + } + else if (!paneInfo.isDraggingScrollbarY) { + paneInfo.isHoveringScrollbarY = false + } + + if (y >= dims.info.rect.h - dims.info.scrollbarHandleSize && + !paneInfo.isDraggingScrollbarY && + draw.info.scrollbarX.isVisible + ) { + paneInfo.isHoveringScrollbarX = true + } + else if (!paneInfo.isDraggingScrollbarX) { + paneInfo.isHoveringScrollbarX = false + } + }) + + function handleWheel() { + $.flush() + + if (mouse.onWheel()) return + + const { x, y } = info.wheel + + if (Math.abs(y) > Math.abs(x)) { + dims.info.scrollY -= y * .35 + } + else { + dims.info.scrollX -= x * .35 + } + } + + function handleDown() { + $.flush() + + info.isDown = true + + if (info.buttons & MouseButtons.Left) { + // handle scrollbarY down + if (paneInfo.isHoveringScrollbarY) { + paneInfo.isDraggingScrollbarY = true + scrollbarPointStart = info.actual.y + scrollbarScrollStart = dims.info.scrollY + return + } + + // handle scrollbarX down + if (paneInfo.isHoveringScrollbarX) { + paneInfo.isDraggingScrollbarX = true + scrollbarPointStart = info.actual.x + scrollbarScrollStart = dims.info.scrollX + return + } + } + + if (mouse.onDown()) return + + paneInfo.isFocus = true + + if (info.buttons & MouseButtons.Middle) { + // TODO: implement middle click + return + } + + if (info.buttons & MouseButtons.Right) { + // TODO: implement right click + return + } + + clearTimeout(info.clickTimeout) + + info.clickTimeout = setTimeout(() => info.count = 0, CLICK_TIMEOUT) + + info.count++ + + switch (info.count) { + case 1: { + selection.reset() + Object.assign(caret, info.linecol) + caret.visualXIntent = caret.col + $.flush() + selection.reset() + break + } + case 2: selection.selectWord(); break + case 3: selection.selectBlock(); break + case 4: selection.selectLine(); break + default: { + selection.reset() + info.count = 0 + break + } + } + caret.info.isBlink = false + } + + function handleUp() { + $.flush() + + info.isDown = false + paneInfo.isDraggingScrollbarX = false + paneInfo.isDraggingScrollbarY = false + + if (mouse.onUp()) return + + info.buttons = 0 + caret.info.isBlink = true + } + + function handleMove() { + $.flush() + + if (paneInfo.isDraggingScrollbarY) { + const { h: outerHeight } = dims.info.rect + const { y: innerHeight } = dims.info.innerSize + const coeff = outerHeight / innerHeight + dims.info.scrollY = scrollbarScrollStart - ((info.actual.y - scrollbarPointStart) / coeff) + return + } + + if (paneInfo.isDraggingScrollbarX) { + const { w: outerWidth } = dims.info.rect + const { x: innerWidth } = dims.info.innerSize + const coeff = outerWidth / innerWidth + dims.info.scrollX = scrollbarScrollStart - ((info.actual.x - scrollbarPointStart) / coeff) + return + } + + if (mouse.onMove()) return + + if (info.buttons & MouseButtons.Left) { + Object.assign(caret, info.linecol) + $.flush() + selection.toCaret() + } + } + + // allow mouse down override by the consumer + function onWheel(): boolean | void { } + function onDown(): boolean | void { } + function onUp(): boolean | void { } + function onMove(): boolean | void { } + + const mouse = { + info, + handleWheel, + handleDown, + handleUp, + handleMove, + onWheel, + onDown, + onUp, + onMove, + } + + return mouse +} diff --git a/src/ui/editor/pane.ts b/src/ui/editor/pane.ts new file mode 100644 index 0000000..146473d --- /dev/null +++ b/src/ui/editor/pane.ts @@ -0,0 +1,65 @@ +import { Buffer, Caret, Dims, Draw, History, Kbd, Misc, Mouse, Selection, type InputHandlers, type Rect, type WordWrapProcessor } from 'editor' +import { Sigui, type $, type Signal } from 'sigui' +import type { Source, Token } from '~/src/lang/tokenize.ts' +import type { View } from '~/src/ui/editor/view.tsx' + +export interface PaneInfo { + isFocus: boolean + isHovering: boolean + isHoveringScrollbarX: boolean + isHoveringScrollbarY: boolean + isDraggingScrollbarX: boolean + isDraggingScrollbarY: boolean +} + +export type Pane = ReturnType + +export function Pane({ misc, view, code, rect, colorize, tokenize, wordWrapProcessor, inputHandlers }: { + misc: Misc + view: View + code: Signal + rect: $ + colorize: (token: Token) => { fill: string, stroke: string } + tokenize: (source: Source) => Generator + wordWrapProcessor: WordWrapProcessor + inputHandlers: InputHandlers +}) { + using $ = Sigui() + + const info: PaneInfo = $({ + isFocus: false, + isHovering: false, + isHoveringScrollbarX: false, + isHoveringScrollbarY: false, + isDraggingScrollbarX: false, + isDraggingScrollbarY: false, + } satisfies PaneInfo) + + const dims = Dims({ rect }) + const buffer = Buffer({ dims, code, tokenize, wordWrapProcessor }) + const caret = Caret({ paneInfo: info, buffer }) + const selection = Selection({ buffer, caret }) + const history = History({ selection, buffer, caret }) + const kbd = Kbd({ paneInfo: info, misc, dims, selection, buffer, caret, history }) + const draw = Draw({ paneInfo: info, view, selection, caret, dims, buffer, colorize }) + const mouse = Mouse({ paneInfo: info, dims, selection, caret, draw }) + const pane = { + info, + view, + dims, + buffer, + caret, + selection, + history, + kbd, + draw, + mouse, + } + kbd.onKeyDown = () => inputHandlers.onKeyDown(pane) + kbd.onKeyUp = () => inputHandlers.onKeyUp(pane) + mouse.onWheel = () => inputHandlers.onMouseWheel(pane) + mouse.onDown = () => inputHandlers.onMouseDown(pane) + mouse.onUp = () => inputHandlers.onMouseUp(pane) + mouse.onMove = () => inputHandlers.onMouseMove(pane) + return pane +} diff --git a/src/ui/editor/selection.ts b/src/ui/editor/selection.ts new file mode 100644 index 0000000..28c541e --- /dev/null +++ b/src/ui/editor/selection.ts @@ -0,0 +1,160 @@ +import { BRACKET, Buffer, findMatchingBrackets, Linecol, pointToLinecol, type Caret } from 'editor' +import { Sigui } from 'sigui' +import { assign } from 'utils' + +export type Selection = ReturnType + +export function Selection({ buffer, caret }: { + buffer: Buffer + caret: Caret +}) { + using $ = Sigui() + + const info = $({ + start: $(Linecol()), + end: $(Linecol()), + startIndex: 0, + endIndex: 0, + sorted: $({ + get start() { + const { line: l1, col: c1 } = info.start + const { line: l2, col: c2 } = info.end + return l1 < l2 || (l1 === l2 && c1 < c2) ? info.start : info.end + }, + get end() { + return info.end === info.sorted.start ? info.start : info.end + }, + get startIndex() { + return info.startIndex < info.endIndex ? info.startIndex : info.endIndex + }, + get endIndex() { + return info.endIndex > info.startIndex ? info.endIndex : info.startIndex + }, + get forward() { + return info.startIndex < info.endIndex + }, + }), + get isActive() { + return info.startIndex !== info.endIndex + }, + get text() { + return buffer.code.slice(info.startIndex, info.endIndex) + }, + }) + + // update start and end index + $.fx(() => { + const { line: y1, col: x1 } = info.start + const { line: y2, col: x2 } = info.end + $() + info.startIndex = buffer.visualPointToIndex({ x: x1, y: y1 }) + info.endIndex = buffer.visualPointToIndex({ x: x2, y: y2 }) + }) + + // update points from start and end index + $.fx(() => { + const { startIndex, endIndex } = info + $() + assign(info.start, pointToLinecol(buffer.indexToVisualPoint(startIndex))) + assign(info.end, pointToLinecol(buffer.indexToVisualPoint(endIndex))) + }) + + function reset() { + info.start.line = info.end.line = caret.visual.y + info.start.col = info.end.col = caret.visual.x + $.flush() + } + + function toCaret() { + info.end.line = caret.visual.y + info.end.col = caret.visual.x + $.flush() + } + + function deleteText() { + const { startIndex, endIndex } = info.sorted + const { code } = buffer + caret.index = startIndex + $.flush() + buffer.code = code.slice(0, startIndex) + code.slice(endIndex) + reset() + $.flush() + } + + function selectAll() { + info.start.line = 0 + info.start.col = 0 + info.end.line = buffer.info.linesVisual.length - 1 + info.end.col = buffer.info.linesVisual.at(-1).text.length + $.flush() + } + + function selectWord(expand = false) { + const word = buffer.wordUnderVisualPoint(caret.visual) + if (word) { + const { y } = caret.visual + const start = { col: word.index, line: y } + const end = { col: word.index + word[0].length, line: y } + if (expand) { + assign(info.end, info.sorted.forward ? end : start) + } + else { + assign(info.start, start) + assign(info.end, end) + } + + // We exclude brackets from being selected as words, so + // that we fallthrough to a matching brackets selection. + if (word[0].length === 1 && BRACKET.test(word[0])) return false + return Boolean(word[0].trim().length) + } + return false + } + + function selectBlock(exclusive?: boolean) { + const index = buffer.visualPointToIndex(caret.visual) + const match = findMatchingBrackets(buffer.code, index) + if (match) { + const exn = Number(exclusive ?? 0) + let start = match[0] + exn + let end = match[1] - exn + 1 + // swap direction depending on which side we are closest. + if (Math.abs(end - index) > Math.abs(start - index)) { + [start, end] = [end, start] + } + assign(info.start, pointToLinecol(buffer.indexToVisualPoint(start))) + assign(info.end, pointToLinecol(buffer.indexToVisualPoint(end))) + Object.assign(caret, info.end) + return true + } + return false + } + + function selectLine() { + const { y } = caret + const line = buffer.info.lines[y] + const start = buffer.logicalPointToIndex({ x: 0, y }) + const end = buffer.logicalPointToIndex({ x: line.length, y }) + assign(info.start, pointToLinecol(buffer.indexToVisualPoint(start))) + assign(info.end, pointToLinecol(buffer.indexToVisualPoint(end))) + Object.assign(caret, info.end) + } + + return $({ + info, + start: info.start, + end: info.end, + startIndex: info.$.startIndex, + endIndex: info.$.endIndex, + isActive: info.$.isActive, + text: info.$.text, + sorted: info.sorted, + reset, + toCaret, + deleteText, + selectAll, + selectWord, + selectBlock, + selectLine, + }) +} diff --git a/src/ui/editor/util/begin-of-line.ts b/src/ui/editor/util/begin-of-line.ts new file mode 100644 index 0000000..66c4450 --- /dev/null +++ b/src/ui/editor/util/begin-of-line.ts @@ -0,0 +1,3 @@ +export function beginOfLine(line: string) { + return line.match(/[^\s]|$/m)!.index! +} diff --git a/src/ui/editor/util/escape-regexp.ts b/src/ui/editor/util/escape-regexp.ts new file mode 100644 index 0000000..097477a --- /dev/null +++ b/src/ui/editor/util/escape-regexp.ts @@ -0,0 +1,3 @@ +export function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/ui/editor/util/find-matching-brackets.ts b/src/ui/editor/util/find-matching-brackets.ts new file mode 100644 index 0000000..c2edc0e --- /dev/null +++ b/src/ui/editor/util/find-matching-brackets.ts @@ -0,0 +1,65 @@ +export const Open = { + '(': ')', + '[': ']', + '{': '}', +} as any + +export const Close = { + ')': '(', + ']': '[', + '}': '{', +} as any + +export const openers = new Set(Object.keys(Open)) +export const closers = new Set(Object.keys(Close)) + +export function findMatchingBrackets(s: string, i: number): [number, number] | undefined { + let char: string + const stack: string[] = [] + let max = 1000 + + --i + + const L = s[i] + const R = s[i + 1] + const LO = Open[L] + const RO = Open[R] + const LC = Close[L] + const RC = Close[R] + + if (LC && RO) i++ + else if ((LO || RO) && (LC || RC)) { } + else if (RO && !LO) i++ + else if (LC && !RC) i-- + + while (i >= 0) { + if (!--max) return + char = s[i--]! + if (closers.has(char)) { + stack.push(Close[char]) + } + else if (stack.at(-1) === char) { + stack.pop() + } + else if (openers.has(char)) { + stack.push(char) + break + } + } + const openIndex = ++i + const open = stack.at(-1) + while (i < s.length) { + if (!--max) return + char = s[i++]! + if (openers.has(char)) { + stack.push(Open[char]) + } + else if (stack.at(-1) === char) { + stack.pop() + if (stack.length === 1 && Close[char] === open) return [openIndex, i - 1] + } + else if (closers.has(char)) { + return + } + } +} diff --git a/src/ui/editor/util/floats.ts b/src/ui/editor/util/floats.ts new file mode 100644 index 0000000..bd7d1bb --- /dev/null +++ b/src/ui/editor/util/floats.ts @@ -0,0 +1,54 @@ +import { wasm } from 'gfx' +import { WAVE_MIPMAPS } from '~/as/assembly/gfx/sketch-shared.ts' + +export type Floats = ReturnType + +export function Floats(waveform: Float32Array) { + const len = waveform.length + + const targets = Array.from({ length: WAVE_MIPMAPS }, (_, i) => ({ + divisor: 2 ** (i + 1), + len: 0, + ptr: 0 + })) + + const size = targets.reduce((p, n) => { + n.ptr = p + n.len = Math.floor(len / n.divisor) + return n.ptr + n.len + }, len) + + const floats = Object.assign( + wasm.alloc(Float32Array, size), + { len } + ) + floats.set(waveform) + + for (const { divisor, len, ptr } of targets) { + for (let n = 0; n < len; n++) { + const n0 = Math.floor(n * divisor) + const n1 = Math.ceil((n + 1) * divisor) + + let min = Infinity, max = -Infinity + let s + for (let i = n0; i < n1; i++) { + s = waveform[i] + if (s < min) min = s + if (s > max) max = s + } + + if ( + !isFinite(min) && + !isFinite(max) + ) min = max = 0 + + if (!isFinite(min)) min = max + if (!isFinite(max)) max = min + + const p = ptr + n + floats[p] = Math.abs(min) > Math.abs(max) ? min : max + } + } + + return floats +} diff --git a/src/ui/editor/util/geometry.ts b/src/ui/editor/util/geometry.ts new file mode 100644 index 0000000..ddb5690 --- /dev/null +++ b/src/ui/editor/util/geometry.ts @@ -0,0 +1,5 @@ +import type { Point, Rect } from 'editor' + +export function isPointInRect(p: Point, r: Rect) { + return p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h +} diff --git a/src/ui/editor/util/index.ts b/src/ui/editor/util/index.ts new file mode 100644 index 0000000..03613c9 --- /dev/null +++ b/src/ui/editor/util/index.ts @@ -0,0 +1,11 @@ +export * from './begin-of-line.ts' +export * from './escape-regexp.ts' +export * from './find-matching-brackets.ts' +export * from './floats.ts' +export * from './geometry.ts' +export * from './oklch.ts' +export * from './parse-words.ts' +export * from './regexp.ts' +export * from './rgb.ts' +export * from './types.ts' +export * from './waveform.ts' diff --git a/src/ui/editor/util/oklch.ts b/src/ui/editor/util/oklch.ts new file mode 100644 index 0000000..b767aa1 --- /dev/null +++ b/src/ui/editor/util/oklch.ts @@ -0,0 +1,57 @@ +// chatgpt + +export function oklchToHex(oklchString: string): string | null { + // Extracting values from the oklch string + const match = oklchString.match(/oklch\((\d+)%\s+(\d+)\s+(\d+)\)/) + + if (!match) { + // Invalid input format + return null + } + + const lightness = parseInt(match[1], 10) + const chroma = parseInt(match[2], 10) + const hue = parseInt(match[3], 10) + + // Convert oklch to RGB + const rgb = oklchToRGB(lightness, chroma, hue) + + // Convert RGB to hexadecimal + const hex = rgbToHex(rgb) + + return hex +} + +function oklchToRGB(lightness: number, chroma: number, hue: number): number[] { + const h = hue / 360 + const s = chroma / 100 + const l = lightness / 100 + + const x = chroma * (1 - Math.abs((h * 6) % 2 - 1)) + + let r, g, b + + if (0 <= h && h < 1) { + [r, g, b] = [chroma, x, 0] + } else if (1 <= h && h < 2) { + [r, g, b] = [x, chroma, 0] + } else if (2 <= h && h < 3) { + [r, g, b] = [0, chroma, x] + } else if (3 <= h && h < 4) { + [r, g, b] = [0, x, chroma] + } else if (4 <= h && h < 5) { + [r, g, b] = [x, 0, chroma] + } else if (5 <= h && h < 6) { + [r, g, b] = [chroma, 0, x] + } else { + [r, g, b] = [0, 0, 0] + } + + const m = l - chroma / 2 + + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)] +} + +function rgbToHex(rgb: number[]): string { + return '#' + rgb.map(value => value.toString(16).padStart(2, '0')).join('') +} diff --git a/src/ui/editor/util/parse-words.ts b/src/ui/editor/util/parse-words.ts new file mode 100644 index 0000000..d7442ec --- /dev/null +++ b/src/ui/editor/util/parse-words.ts @@ -0,0 +1,7 @@ +export function parseWords(regexp: RegExp, text: string) { + regexp.lastIndex = 0 + let word + const words: RegExpExecArray[] = [] + while ((word = regexp.exec(text))) words.push(word) + return words +} diff --git a/src/ui/editor/util/regexp.ts b/src/ui/editor/util/regexp.ts new file mode 100644 index 0000000..a0fa280 --- /dev/null +++ b/src/ui/editor/util/regexp.ts @@ -0,0 +1,5 @@ +export const NONSPACE = /[^\s]/g +export const SPACE = /\s/g +export const WORD = /\n|[\s]{2,}|[./\\()"'\-:,.;<>~!@#$%^&*|+=[\]{}`~?\b ]{1}|\w+/g +export const TOKEN = /\s+|[\w\.]+|[\W]/g +export const BRACKET = /[\[\]\(\)\{\}]/ // used with .test so not a /g diff --git a/src/ui/editor/util/rgb.ts b/src/ui/editor/util/rgb.ts new file mode 100644 index 0000000..790c9b4 --- /dev/null +++ b/src/ui/editor/util/rgb.ts @@ -0,0 +1,33 @@ +import { hexToRgb } from 'utils' +import { oklchToHex } from './oklch.ts' + +type f32 = number +type i32 = number + +function i32(x: number) { + return Math.floor(x) +} + +export function rgbToInt(r: f32, g: f32, b: f32): i32 { + return (clamp255(r * 255) << 16) | (clamp255(g * 255) << 8) | clamp255(b * 255) +} + +export function hexToInt(hex: string) { + const [r, g, b] = hexToRgb(hex) + return rgbToInt(r, g, b) +} + +export function intToHex(x: number) { + return '#' + x.toString(16).padStart(6, '0') +} + +export function clamp255(x: f32): i32 { + if (x > 255) x = 255 + if (x < 0) x = 0 + return i32(x) +} + +export function toHex(x: string) { + return !x ? '#ffffff' : x.startsWith('oklch') ? oklchToHex(x) ?? x : x +} + diff --git a/src/ui/editor/util/types.ts b/src/ui/editor/util/types.ts new file mode 100644 index 0000000..538f22f --- /dev/null +++ b/src/ui/editor/util/types.ts @@ -0,0 +1,25 @@ +export type Rect = ReturnType + +export function Rect() { + return { x: 0, y: 0, w: 0, h: 0 } +} + +export type Point = ReturnType + +export function Point() { + return { x: 0, y: 0 } +} + +export type Linecol = ReturnType + +export function Linecol() { + return { line: 0, col: 0 } +} + +export function pointToLinecol(p: Point): Linecol { + return { line: p.y, col: p.x } +} + +export function linecolToPoint(linecol: Linecol): Point { + return { x: linecol.col, y: linecol.line } +} diff --git a/src/ui/editor/util/waveform.ts b/src/ui/editor/util/waveform.ts new file mode 100644 index 0000000..e151f34 --- /dev/null +++ b/src/ui/editor/util/waveform.ts @@ -0,0 +1,9 @@ +const waveformLength = 2048 + +export function makeWaveform(length: number, startTime: number, frequency: number) { + return Float32Array.from({ length }, (_, i) => + Math.sin(((i + startTime) / length) * Math.PI * 2 * frequency) + ) +} + +export const waveform = makeWaveform(2048, 0, 1) diff --git a/src/ui/editor/view.tsx b/src/ui/editor/view.tsx new file mode 100644 index 0000000..f5e6a78 --- /dev/null +++ b/src/ui/editor/view.tsx @@ -0,0 +1,80 @@ +import { Anim, Gfx } from 'gfx' +import { Signal, Sigui } from 'sigui' +import { Canvas } from '../Canvas.tsx' +import { screen } from '~/src/screen.ts' + +export type View = ReturnType + +export function View({ width, height }: { + width: Signal + height: Signal +}) { + using $ = Sigui() + + const info = $({ + pr: screen.$.pr, + cursor: 'default', + width, + height, + svgs: new Set(), + }) + + const canvas = as HTMLCanvasElement + + const glCanvas = as HTMLCanvasElement + + const svg = + {() => [...info.svgs]} + as SVGSVGElement + + const textarea =