From 09e4bfc816e89cc7f4822f174f4900ccd3279b73 Mon Sep 17 00:00:00 2001 From: Sam Thorogood Date: Tue, 17 Mar 2026 23:36:56 +1100 Subject: [PATCH 1/3] send cursors (TODO: display cursors) --- client/bin/app.ts | 40 +++++++++++++++++++++++++++++++++++++++- client/editor.ts | 14 ++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/client/bin/app.ts b/client/bin/app.ts index 215463a..7d494b1 100644 --- a/client/bin/app.ts +++ b/client/bin/app.ts @@ -1,10 +1,11 @@ import { TermEditor } from '../editor.ts'; import { GumnutFloor } from '../lib/gumnut/floor.ts'; +import { InoPosRef } from '../lib/gumnut/types.ts'; import { connectToPulse } from '../lib/pulse/conn.ts'; import type { InPacket, OutPacket } from '../lib/ymodel/types.ts'; -const SEND_DELAY = 125; const FIXED_STRING = 'fixed-string'; +const FIXED_CURSORS = 'cursors'; const FIXED_NUM = 'num'; const c = new AbortController(); @@ -12,6 +13,19 @@ const e = new TermEditor(c.signal); const floor = new GumnutFloor(); const model = floor.byIno(FIXED_STRING); +let cursorAt: InoPosRef | null = null; + +function reconcileOtherCursors() { + // TODO: replace other user cursors + const cursors = floor.byIno(FIXED_CURSORS); + const selfCursorPos = cursorAt ? floor.resolveRef(cursorAt) : 0; + for (let i = 0; i < cursors.length(); ++i) { + if (selfCursorPos - 1 === i) { + continue; + } + // TODO: someone else + } +} e.addListener( 'modify', @@ -22,6 +36,7 @@ e.addListener( }); e.text = model.asString(); + reconcileOtherCursors(); // readjust cursor and collapse selection e.pos = m.lo + m.insert.length; @@ -39,6 +54,27 @@ e.addListener( c.signal, ); +e.addListener( + 'cursor', + () => { + floor.perform(function* (byIno, resolveRef) { + const c = byIno(FIXED_CURSORS); + const text = byIno(FIXED_STRING); + + let pos = cursorAt ? resolveRef(cursorAt) : 0; + if (pos === 0) { + // insert self + c.splice(0, 0, null); + cursorAt = c.refAt(1); // TODO: this is pos, not index + pos = 1; + } + + c.set(pos - 1, text.refAt(e.pos)); + }); + }, + c.signal, +); + e.addListener( 'submit', () => { @@ -120,6 +156,8 @@ for (;;) { // TODO: we assume FIXED_STRING changes (not always true) e.text = model.asString(); + reconcileOtherCursors(); + e.pos = floor.resolveRef(cursor); e.anchor = floor.resolveRef(anchor); diff --git a/client/editor.ts b/client/editor.ts index 813188f..3997fb5 100644 --- a/client/editor.ts +++ b/client/editor.ts @@ -15,6 +15,7 @@ export interface TermEditorEvents { submit: void; escape: void; modify: { lo: number; hi: number; insert: string }; + cursor: number; // TODO: just cursor now, not anchor } export class TermEditor { @@ -55,6 +56,7 @@ export class TermEditor { if (!extending) { this._anchor = this._pos; } + this.events.dispatch('cursor', this._pos); this.triggerRender(); } @@ -65,6 +67,7 @@ export class TermEditor { if (!extending) { this._anchor = this._pos; } + this.events.dispatch('cursor', this._pos); this.triggerRender(); } @@ -216,6 +219,7 @@ export class TermEditor { case '\x01': this._anchor = 0; this._pos = this._text.length; + this.events.dispatch('cursor', this._pos); this.triggerRender(); return; @@ -518,8 +522,13 @@ export class TermEditor { set text(v: string) { this._text = v; - this._pos = Math.min(this._pos, v.length); - this._anchor = Math.min(this._anchor, v.length); + + if (this._pos > v.length || this._anchor > v.length) { + this._pos = Math.min(this._pos, v.length); + this._anchor = Math.min(this._anchor, v.length); + this.events.dispatch('cursor', this._pos); + } + this.triggerRender(); } @@ -531,6 +540,7 @@ export class TermEditor { const update = Math.max(0, Math.min(p, this._text.length)); if (this._pos !== update) { this._pos = update; + this.events.dispatch('cursor', this._pos); this.triggerRender(); } } From f91155e3569ef1c84aad93b9164877abf532fa07 Mon Sep 17 00:00:00 2001 From: Sam Thorogood Date: Tue, 17 Mar 2026 23:57:48 +1100 Subject: [PATCH 2/3] render cursors --- client/bin/app.ts | 15 +++++++++++++++ client/editor.ts | 44 ++++++++++++++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 14 deletions(-) mode change 100644 => 100755 client/bin/app.ts diff --git a/client/bin/app.ts b/client/bin/app.ts old mode 100644 new mode 100755 index 7d494b1..820a999 --- a/client/bin/app.ts +++ b/client/bin/app.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env -S node --disable-warning=ExperimentalWarning --experimental-transform-types + import { TermEditor } from '../editor.ts'; import { GumnutFloor } from '../lib/gumnut/floor.ts'; import { InoPosRef } from '../lib/gumnut/types.ts'; @@ -19,12 +21,25 @@ function reconcileOtherCursors() { // TODO: replace other user cursors const cursors = floor.byIno(FIXED_CURSORS); const selfCursorPos = cursorAt ? floor.resolveRef(cursorAt) : 0; + + const cursorsToRender = new Map(); + for (let i = 0; i < cursors.length(); ++i) { if (selfCursorPos - 1 === i) { continue; } // TODO: someone else + + const v = cursors.at(i); + if (!(v instanceof InoPosRef)) { + continue; + } + + const pos = floor.resolveRef(v); + cursorsToRender.set(String(i), pos); } + + e.cursor = cursorsToRender; } e.addListener( diff --git a/client/editor.ts b/client/editor.ts index 3997fb5..e7e5b58 100644 --- a/client/editor.ts +++ b/client/editor.ts @@ -52,27 +52,39 @@ export class TermEditor { private moveCursor(newPos: number, extending: boolean) { this._stickyCol = -1; - this._pos = Math.max(0, Math.min(newPos, this._text.length)); - if (!extending) { - this._anchor = this._pos; + + const updatePos = Math.max(0, Math.min(newPos, this._text.length)); + const updateAnchor = extending ? this._anchor : updatePos; + + if (updatePos !== this._pos || updateAnchor !== this._anchor) { + this._pos = updatePos; + this._anchor = updateAnchor; + + this.dispatchCursor(); + this.triggerRender(); } - this.events.dispatch('cursor', this._pos); - this.triggerRender(); } private moveCursorVertical(direction: -1 | 1, extending: boolean) { const r = TermEditor.moveVertical(this._text, this._pos, direction, this._stickyCol); this._stickyCol = r.stickyCol; - this._pos = r.pos; - if (!extending) { - this._anchor = this._pos; + + const updatePos = r.pos; + const updateAnchor = extending ? this._anchor : updatePos; + + if (updatePos !== this._pos || updateAnchor !== this._anchor) { + this._pos = updatePos; + this._anchor = updateAnchor; + + this.dispatchCursor(); + this.triggerRender(); } - this.events.dispatch('cursor', this._pos); - this.triggerRender(); } private copySelection(): void { - if (!this.hasSelection) return; + if (!this.hasSelection) { + return; + } this._clipboardBuffer = this._text.slice(this.selectionStart, this.selectionEnd); const encoded = Buffer.from(this._clipboardBuffer).toString('base64'); stdout.write(`\x1b]52;c;${encoded}\x07`); @@ -219,7 +231,7 @@ export class TermEditor { case '\x01': this._anchor = 0; this._pos = this._text.length; - this.events.dispatch('cursor', this._pos); + this.dispatchCursor(); this.triggerRender(); return; @@ -295,6 +307,10 @@ export class TermEditor { }); } + private dispatchCursor() { + this.events.dispatch('cursor', this._pos); + } + public detach() { const rowsDown = this.lastTotalRows - this.lastRenderRows; if (rowsDown > 0) { @@ -526,7 +542,7 @@ export class TermEditor { if (this._pos > v.length || this._anchor > v.length) { this._pos = Math.min(this._pos, v.length); this._anchor = Math.min(this._anchor, v.length); - this.events.dispatch('cursor', this._pos); + this.dispatchCursor(); } this.triggerRender(); @@ -540,7 +556,7 @@ export class TermEditor { const update = Math.max(0, Math.min(p, this._text.length)); if (this._pos !== update) { this._pos = update; - this.events.dispatch('cursor', this._pos); + this.dispatchCursor(); this.triggerRender(); } } From 839568fd51f5112df46d0e1bb225b9f58d914934 Mon Sep 17 00:00:00 2001 From: Sam Thorogood Date: Wed, 18 Mar 2026 00:18:15 +1100 Subject: [PATCH 3/3] fix b/e --- server/pkg/ylayer/doc.go | 12 ++++++++++++ server/pkg/ylayer/node.go | 16 +++++++--------- server/pkg/ymodel/sets.go | 10 ++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/server/pkg/ylayer/doc.go b/server/pkg/ylayer/doc.go index a5d9285..5f1fc30 100644 --- a/server/pkg/ylayer/doc.go +++ b/server/pkg/ylayer/doc.go @@ -76,6 +76,8 @@ func (d *docImpl) Apply(work *DocWork) (err error) { out[ino] = res } + // --- at this point we are going to apply or panic --- + // record last active version for this handle if work.Handle > 0 { handleVersion := work.TargetVersion @@ -104,6 +106,16 @@ func (d *docImpl) Apply(work *DocWork) (err error) { work.Update[ino] = res.entry.p } + // xform sets we did here + // TODO: leaky abstraction, we poke in/out of node + for ino := range out { + nh := d.byIno[ino] + last := &nh.history[len(nh.history)-1] + last.p.Set.IterAll(func(x *GumnutData) { + d.moveForward(x, arg.targetVersion, arg.handle) + }) + } + return nil } diff --git a/server/pkg/ylayer/node.go b/server/pkg/ylayer/node.go index 2d93f47..068182f 100644 --- a/server/pkg/ylayer/node.go +++ b/server/pkg/ylayer/node.go @@ -69,14 +69,11 @@ func (nh *nodeHolder) read() (gd GumnutDataArray, err error) { } clone := entry.p.Set - for i := range clone { - for y := range clone[i].Body { - // we NEVER transformed this set against its own run, so we have to do -1 here - // TODO: to do this in prepare()/enact() is hard - we might target another inode! - // TODO: but we haven't checked if their -ve was invalid - we zero it inside moveForward - nh.doc.moveForward(&clone[i].Body[y], entry.version-1, entry.handle) - } - } + + // xform sets from where they were valid to now + clone.IterAll(func(x *GumnutData) { + nh.doc.moveForward(x, entry.version, entry.handle) + }) ok := clone.ApplySlice(nh.data) if !ok { return nil, ErrSetOp @@ -175,7 +172,8 @@ func (nh *nodeHolder) prepare(arg prepareArg, p *Patch) (res prepareResult, err err = ErrSetOp return } - // nb. entry.Set might still contain -ve refs to own work + + // nb. entry.Set might still contain -ve refs to own work, we fix "externally" (leaky abstraction) res = prepareResult{ length: length, diff --git a/server/pkg/ymodel/sets.go b/server/pkg/ymodel/sets.go index 30d949d..438a20d 100644 --- a/server/pkg/ymodel/sets.go +++ b/server/pkg/ymodel/sets.go @@ -14,6 +14,16 @@ type Set[X any] struct { // ManySet is a slice of Set. type ManySet[X any] []Set[X] +// IterAll calls the provided function with a reference to every X here. +func (s ManySet[X]) IterAll(fn func(x *X)) { + for i := range s { + set := &s[i] + for i := range set.Body { + fn(&set.Body[i]) + } + } +} + // ApplySlice applies an already-transformed ManySet to the target. func (s ManySet[X]) ApplySlice(target []X) (ok bool) { // check all parts first