From c3afa6625fc67e2cd1df9a86b6379f26efc347ca Mon Sep 17 00:00:00 2001 From: varrockbank Date: Mon, 12 Jan 2026 23:42:58 +0100 Subject: [PATCH 01/50] breaking: remove automatic tab expansion, add BuffeeSanitize extension - Remove expandTabs function from core - text inserted as-is - Add BuffeeSanitize extension for opt-in sanitization: - Converts tabs to spaces based on Mode.s - Removes zero-width chars (ZWSP, ZWNJ, ZWJ, BOM) - Normalizes multi-width Unicode spaces - API: Sanitize.line(), text(), lines() - Update docs with sanitization guidance - Add navigation link to API docs --- CLAUDE.md | 1 + README.md | 21 ++++++++-- buffee.js | 17 ++++---- dev/changelog.txt | 10 +++++ dist/buffee.min.js | 2 +- docs/api.md | 16 +++++++ extensions/sanitize.js | 95 ++++++++++++++++++++++++++++++++++++++++++ test/index.html | 1 + web/extensions.html | 20 +++++++++ web/navigation.html | 1 + 10 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 extensions/sanitize.js diff --git a/CLAUDE.md b/CLAUDE.md index 493d9987..76b478dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,7 @@ Located in `extensions/`, tested in "Extensions" tab of `test/index.html`. | `tui.js` | `BuffeeTUI(editor)` | Text-based UI via text manipulation | | `ios.js` | `BuffeeIOS(editor)` | iOS touch/keyboard support | | `fileloader.js` | `BuffeeFileLoader(editor)` | File loading strategies | +| `sanitize.js` | `BuffeeSanitize(editor)` | Tab/Unicode sanitization | | `ultrahighcapacity.js` | `BuffeeUltraHighCapacity(editor)` | 1B+ line support | | `treesitter.js` | `BuffeeTreeSitter(editor, opts)` | Tree-sitter integration | diff --git a/README.md b/README.md index f2457482..9e5c50f5 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,31 @@ Finally, (V8) arrays, not being real arrays, prove miraculuously viable as a buf ## Usage -### Font Requirements +### Monowidth Character Handling + +Buffee's fixed-width grid layout requires all characters to occupy exactly one cell. This section covers common issues that break grid alignment. + +#### Font Requirements Buffee assumes monospace fonts having accurate CSS `ch` values. If this assumption breaks, the cursor position -will be visually misaligned from true position. This is evident with variable-width -text but some monospace fonts can cause "drift", fractions of a pixel per character, that accumulate numerical errors. +will be visually misaligned from true position. This is evident with variable-width +text but some monospace fonts can cause "drift", fractions of a pixel per character, that accumulate numerical errors. - **Good:** Menlo, Consolas, `monospace` (generic) - **Bad:** Monaco To test: type "A" 100+ times and move cursor to end. If misaligned, try a different font. +#### Tab Sanitization + +Tab characters (`\t`) break grid alignment because browsers render them as variable-width. Buffee core does not sanitize input—if you set content containing tabs via `Model.s` or `Span.ins()`, they appear as-is. + +**Solutions:** +- **BuffeeSanitize extension** — Automatically converts tabs to spaces, removes zero-width characters, and normalizes multi-width Unicode spaces. See [Sanitize extension](web/extensions.html#sanitize). +- **Pre-sanitize** — Clean your text before passing to Buffee: `text.replace(/\t/g, ' ')` + +The keyboard controller already handles Tab key presses by inserting spaces (based on `Mode.s`), so typed tabs are not an issue—only programmatic content. + ### CSS [style.css](style.css) contains structural styles. Bring-your-own cursor and selection color: @@ -166,6 +180,7 @@ Available extensions: - **FileLoader** - Multiple strategies for large file loading - **UltraHighCapacity** - Gzip-compressed storage for 1B+ lines - **iOS** - Touch and on-screen keyboard support +- **Sanitize** - Tab/Unicode normalization for programmatic content See: [Extensions](docs/extensions.md) diff --git a/buffee.js b/buffee.js index fea208f1..9e327026 100644 --- a/buffee.js +++ b/buffee.js @@ -17,9 +17,8 @@ * editor.Model.s = 'Hello, World!'; */ function Buffee($, { rows, cols, s = 4 } = {}) { - this.v = '14.40.0-alpha.1'; + this.v = '15.0.0-alpha.1'; this.$ = $; - const expandTabs = s => Mode.s ? s.replace(/\t/g, ' '.repeat(Mode.s)) : s; // 0 = retain tabs const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u; // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative). // This allows selections to span beyond the viewport. @@ -164,7 +163,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { * @param {string} s - String to insert */ ins(s) { - const lines = expandTabs(s).split('\n'); + const lines = s.split('\n'); if (this.dir) { const [first, second] = Span.bounds(1); Model.del(first.y, first.x, second.y, second.x + (this.dir > 0)); @@ -244,7 +243,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { * @namespace Mode */ const Mode = this.Mode = { - s, /** spaces */ + s, /** spaces */ /** * Interactive mode: 1 (normal), 0 (navigation-only), -1 (read-only) * - 1: Full editing (default) @@ -252,11 +251,11 @@ function Buffee($, { rows, cols, s = 4 } = {}) { * - -1: Read-only (no cursor/selection rendering, no navigation) - used by TUI * @type {-1|0|1} */ - i: 1, - f: 0, /** framecount */ + i: 1, + f: 0, /** framecount */ ch, /** line and character height */ - cw: $caret.getBoundingClientRect().width, /** computed character width */ - sub: [] + cw: $caret.getBoundingClientRect().width, /** computed character width */ + sub: [] /** render callbacks */ }; /** @@ -278,7 +277,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { * @param {string} text - The full document text */ set s(text) { - this._ = expandTabs(text).split('\n'); + this._ = text.split('\n'); render(); }, diff --git a/dev/changelog.txt b/dev/changelog.txt index 1e4ab801..a7e68847 100644 --- a/dev/changelog.txt +++ b/dev/changelog.txt @@ -1,5 +1,15 @@ * Project Devlog +** 15.0.0-alpha.1 [2026-01-12] gz: 2597 / 2.54 KB (-29), br: 2392 / 2.34 KB (-30), min: 5812 / 5.68 KB (-51) +- breaking: remove automatic tab expansion from core +- Buffee no longer sanitizes text - tabs/Unicode inserted as-is +- Users should sanitize text before passing to Model.s or Span.ins +- new: BuffeeSanitize extension for opt-in text sanitization + - Converts tabs to spaces (based on Mode.s) + - Removes zero-width characters (ZWSP, ZWNJ, ZWJ, BOM) + - Normalizes multi-width Unicode spaces to regular space + - API: Sanitize.line(), Sanitize.text(), Sanitize.lines() + ** Extensions [2026-01-12] - fix(treesitter.js 1.0.2): use tree-sitter API row/column, viewport.n not viewport.size - fix(tui.js 3.0.2): use row/col for elements, viewport.n not viewport.size diff --git a/dist/buffee.min.js b/dist/buffee.min.js index 5419dc8e..b7cd7ded 100644 --- a/dist/buffee.min.js +++ b/dist/buffee.min.js @@ -1 +1 @@ -function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="14.40.0-alpha.1",this.$=t;const i=t=>E.s?t.replace(/\t/g," ".repeat(E.s)):t,l=/\s/,r=/[\p{L}\p{Nd}_]/u,c={y:0,x:0};let h={y:0,x:0},o=h,a=h.x;const[y,d,x,f]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[p,u,g,_,m,v,w]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let b=u.getBoundingClientRect();const M=[[v,(t,e)=>t.textContent=L._[C.start+e]??null],[m,(t,e)=>t.textContent=C.start+e+1],[w,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),D=this.Span={bounds:t=>t&&D.dir>0?[o,h]:[h,o],mvY(t,e){if(t>0?h.y0){const s=L._[t>0?++h.y:--h.y].length;h.x=e?t>0?0:s:Math.min(a,s),e&&(a=h.x),h.yC.end?C.set(t>0?h.y-C.n+1:h.y):R()}},mvX(t){const e=t>0;(e?h.x0&&s0;if(h.x!==(n?s:0)){let t=h.x;const i=n?()=>tt>0,c=n?()=>t++:()=>t--;if(l.test(e[t])){for(;i()&&l.test(e[t]);)c();for(;i()&&r.test(e[t]);)c()}else if(r.test(e[t]))for(;i()&&r.test(e[t]);)c();else{const s=e[t];for(c();i()&&e[t]===s;)c()}h.x=t,R()}else(n?h.y0)&&(h.x=n?0:L._[--h.y].length,n&&++h.y>C.end?C.set(h.y-C.n+1):!n&&h.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(h.y=t.y,h.x=t.x),o.y=h.y,o.x=h.x,h=o},select(){h=c,h.y=o.y,h.x=o.x},ins(t){const e=i(t).split("\n");if(this.dir){const[s,n]=D.bounds(1);L.del(s.y,s.x,n.y,n.x+(this.dir>0)),L.ins(s.y,s.x,e),h.y=s.y,e.length>1?(h.y+=e.length-1,h.x=e[e.length-1].length):h.x=s.x+t.length,this.cursor()}else L.ins(h.y,h.x,e),e.length>1?(h.y+=e.length-1,a=h.x=e[e.length-1].length):a=h.x+=t.length;h.y>C.end?C.set(h.y-C.n+1):R()},del(){this.dir?this.ins(""):h.x>0?(L.del(h.y,h.x-1,h.y,h.x),h.x--,R()):h.y>0&&(h.x=L._[h.y-1].length,L.del(h.y-1,h.x,h.y,0),--h.y0&&!this.dir)return;const[e,s]=D.bounds(1);for(let n=e.y;n<=s.y;n++){const i=L._[n];if(t>0)L._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);L._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),R()}},E=this.Mode={s:n,i:1,f:0,ch:y,cw:g.getBoundingClientRect().width,sub:[]},L=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=i(t).split("\n"),R()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},C=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,L.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,L.end)),$(s)},get _(){return L._.slice(this.start,this.end+1)}},$=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)M.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&M.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)M.forEach(([t])=>t.pop()?.remove())}if(m){const t=Math.max(x,(C.start+C.N).toString().length)+f;m.style.width=t+"ch",s&&(p.style.width=`calc(${t+s}ch + ${4*d}px)`)}R(t)},R=this.render=(t=0)=>{E.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(E.i>=0){const[t,s]=D.bounds(1),n=Math.min(C.start+C.n,s.y+1);for(let e=Math.max(C.start,t.y);e=0&&is?l-s:0))/E.cw)*E.cw}}g.style.left=e+"ch",E.sub.forEach(e=>e(u,C,t))};s&&!m&&(p.style.width=`calc(${s}ch + ${2*d}px)`),e&&M.forEach(([,,t])=>t&&(t.style.height=e*y+"px"));const S=t=>{C.n+=t,$(t)};e?S(e):new ResizeObserver(()=>{b=u.getBoundingClientRect(),S(Math.floor(p.clientHeight/y)-C.n)}).observe(p),u.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&D.ins(e)}),_.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",D._.join("\n"))}),_.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",D._.join("\n")),D.del(),u.focus({preventScroll:!0})});const B={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};u.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{_.focus({preventScroll:!0}),_.select()},x:()=>{_.focus({preventScroll:!0}),_.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{D.del()},Enter:()=>{D.ins("\n")},Tab:()=>{t.preventDefault(),D.dir||n?D.dent(n?-E.s:E.s):D.ins(" ".repeat(E.s))}},r=B[s]||0;if(r){if(t.preventDefault(),E.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&D.dir?D.cursor():n&&!D.dir&&D.select(),r%2&&(e?D.mvLn(s>0):D.mvW(s));else if(!n&&D.dir)if(r%2)D.cursor(D.bounds(1)[s>0|0]),R();else{const t=D.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,L.end));a=Math.min(t.x,L._[e].length),D.cursor({y:e,x:a}),eC.end?C.set(e-C.n+1):R()}else n&&!D.dir&&D.select(),D[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():E.i>0&&(" "===s&&t.preventDefault(),D.ins(s)):l[s]&&E.i>=1&&l[s]()})} \ No newline at end of file +function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="15.0.0-alpha.1",this.$=t;const i=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,d,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.start+e]??null],[_,(t,e)=>t.textContent=L.start+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.end?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(n?s:0)){let t=c.x;const r=n?()=>tt>0,h=n?()=>t++:()=>t--;if(i.test(e[t])){for(;r()&&i.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(n?c.y0)&&(c.x=n?0:E._[--c.y].length,n&&++c.y>L.end?L.set(c.y-L.n+1):!n&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){const e=t.split("\n");if(this.dir){const[s,n]=M.bounds(1);E.del(s.y,s.x,n.y,n.x+(this.dir>0)),E.ins(s.y,s.x,e),c.y=s.y,e.length>1?(c.y+=e.length-1,c.x=e[e.length-1].length):c.x=s.x+t.length,this.cursor()}else E.ins(c.y,c.x,e),e.length>1?(c.y+=e.length-1,o=c.x=e[e.length-1].length):o=c.x+=t.length;c.y>L.end?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins(""):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let n=e.y;n<=s.y;n++){const i=E._[n];if(t>0)E._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);E._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:n,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[]},E=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},L=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,E.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,E.end)),C(s)},get _(){return E._.slice(this.start,this.end+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(d,(L.start+L.N).toString().length)+x;_.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),n=Math.min(L.start+L.n,s.y+1);for(let e=Math.max(L.start,t.y);e=0&&is?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(f.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(f.clientHeight/a)-L.n)}).observe(f),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e)}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins("\n")},Tab:()=>{t.preventDefault(),M.dir||n?M.dent(n?-D.s:D.s):M.ins(" ".repeat(D.s))}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&M.dir?M.cursor():n&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!n&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.end));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.end?L.set(e-L.n+1):$()}else n&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins(s)):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index c2949933..617d60b9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,6 +12,22 @@ const editor = new Buffee(element, { rows, cols, s }) | `cols` | number | auto | Fixed text columns | | `s` | number | 4 | Tab width (0 = hard tabs) | +## Text Sanitization + +Buffee does **not** sanitize text. Content set via `Model.s` or `Span.ins()` is inserted as-is. This means: + +- **Tabs** (`\t`) render with browser-default variable width, breaking grid alignment +- **Zero-width characters** (ZWSP, ZWNJ, ZWJ, BOM) cause invisible cursor drift +- **Multi-width Unicode spaces** (em space, en space, etc.) misalign subsequent characters + +**Solutions:** +- Use `BuffeeSanitize` extension for automatic sanitization +- Pre-sanitize text before passing to Buffee + +Note: The keyboard controller converts Tab key presses to spaces—only programmatic content is affected. + +--- + ## Top-level properties ```javascript diff --git a/extensions/sanitize.js b/extensions/sanitize.js new file mode 100644 index 00000000..9caf70fa --- /dev/null +++ b/extensions/sanitize.js @@ -0,0 +1,95 @@ +/** + * @fileoverview BuffeeSanitize - Text sanitization extension for Buffee. + * Converts tabs to spaces and removes/normalizes problematic Unicode characters. + * @version 1.0.0 + */ + +/** + * Decorator: adds text sanitization to a Buffee instance. + * Automatically sanitizes text on insert and content set. + * + * Handles: + * - Tabs → spaces (based on Mode.s) + * - Zero-width characters (ZWSP, ZWNJ, ZWJ, BOM) + * - Multi-width spaces → regular space + * + * @param {Buffee} editor - The Buffee instance to extend + * @returns {Buffee} The extended editor instance + * @example + * const editor = BuffeeSanitize(Buffee(container, config)); + */ +function BuffeeSanitize(editor) { + const { Model, Mode } = editor; + const origIns = Model.ins.bind(Model); + + // Zero-width characters to remove + const zeroWidthRe = /[\u200B\u200C\u200D\uFEFF]/g; + + // Multi-width spaces to normalize (em space, en space, figure space, etc.) + const multiSpaceRe = /[\u2000-\u200A\u202F\u205F\u3000]/g; + + /** + * Sanitize a single line of text. + * @param {string} line - Line to sanitize + * @returns {string} Sanitized line + */ + function sanitizeLine(line) { + let result = line; + // Convert tabs to spaces + if (Mode.s) result = result.replace(/\t/g, ' '.repeat(Mode.s)); + // Remove zero-width characters + result = result.replace(zeroWidthRe, ''); + // Normalize multi-width spaces + result = result.replace(multiSpaceRe, ' '); + return result; + } + + /** + * Sanitize text (may contain newlines). + * @param {string} text - Text to sanitize + * @returns {string} Sanitized text + */ + function sanitizeText(text) { + return text.split('\n').map(sanitizeLine).join('\n'); + } + + /** + * Sanitize an array of lines. + * @param {string[]} lines - Lines to sanitize + * @returns {string[]} Sanitized lines + */ + function sanitizeLines(lines) { + return lines.map(sanitizeLine); + } + + // Wrap Model.ins to sanitize lines + Model.ins = function(row, col, lines) { + origIns(row, col, sanitizeLines(lines)); + }; + + // Wrap Model.s setter to sanitize content + const origSetter = Object.getOwnPropertyDescriptor(Model, 's') || + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Model), 's'); + + Object.defineProperty(Model, 's', { + set(text) { + Model._ = sanitizeText(text).split('\n'); + editor.render(); + }, + configurable: true + }); + + // API + const Sanitize = { + /** Sanitize a single line */ + line: sanitizeLine, + /** Sanitize text with newlines */ + text: sanitizeText, + /** Sanitize array of lines */ + lines: sanitizeLines + }; + + editor.Sanitize = Sanitize; + + return editor; +} diff --git a/test/index.html b/test/index.html index 5fba1c8e..68ef4b28 100644 --- a/test/index.html +++ b/test/index.html @@ -467,6 +467,7 @@

This test setup was AI generated with adult supervision.

+ diff --git a/web/extensions.html b/web/extensions.html index 748b014a..4af01e27 100644 --- a/web/extensions.html +++ b/web/extensions.html @@ -98,6 +98,26 @@

StatusLine

const editor = BuffeeStatusLine(new Buffee(container, config))

Demo →

+

Sanitize

+

extensions/sanitize.js

+

Text sanitization for content set via Model.s or Span.ins(). Buffee core does not sanitize text—tabs and problematic Unicode characters are inserted as-is. This extension automatically cleans input text.

+

Handles:

+
    +
  • Tabs → spaces (based on Mode.s setting)
  • +
  • Zero-width characters → removed (ZWSP, ZWNJ, ZWJ, BOM)
  • +
  • Multi-width Unicode spaces → regular space (em space, en space, figure space, etc.)
  • +
+
const editor = BuffeeSanitize(new Buffee(container, config))
+
+// Automatic sanitization on all inserts
+editor.Model.s = "text\twith\ttabs"  // tabs become spaces
+
+// Manual sanitization utilities
+editor.Sanitize.line("hello\tworld")   // "hello    world"
+editor.Sanitize.text("a\tb\nc\td")     // sanitize multi-line string
+editor.Sanitize.lines(["a\tb", "c\td"]) // sanitize array of lines
+

Why opt-in? Buffee's core is minimal. Automatic sanitization adds overhead and may not suit all use cases. Some applications may want to handle tabs differently, preserve certain Unicode, or sanitize upstream.

+

Using Multiple Extensions

diff --git a/web/navigation.html b/web/navigation.html index af6d2d99..e87e9a61 100644 --- a/web/navigation.html +++ b/web/navigation.html @@ -1,5 +1,6 @@ home | getting started | +api | kitchen sink | extensions | themes | From 03fa756127307e8777b87a22cfbc1222dc57e5a7 Mon Sep 17 00:00:00 2001 From: varrockbank Date: Mon, 12 Jan 2026 23:44:32 +0100 Subject: [PATCH 02/50] feat: add Mode.ext to track registered extensions - Add ext array to Mode for tracking extension registration order - All extensions now push their name to Mode.ext on initialization - Add unit tests for extension registration and chaining patterns --- buffee.js | 5 +- dev/changelog.txt | 5 + dist/buffee.min.js | 2 +- extensions/elementals.js | 1 + extensions/fileloader.js | 1 + extensions/highlights.js | 2 + extensions/history.js | 1 + extensions/ios.js | 1 + extensions/sanitize.js | 1 + extensions/statusline.js | 2 + extensions/syntax.js | 1 + extensions/treesitter.js | 1 + extensions/tui.js | 2 + extensions/ultrahighcapacity.js | 1 + extensions/undotree.js | 2 + test/lib/test-extensions.js | 177 ++++++++++++++++++++++++++++++++ 16 files changed, 202 insertions(+), 3 deletions(-) diff --git a/buffee.js b/buffee.js index 9e327026..463eb26d 100644 --- a/buffee.js +++ b/buffee.js @@ -17,7 +17,7 @@ * editor.Model.s = 'Hello, World!'; */ function Buffee($, { rows, cols, s = 4 } = {}) { - this.v = '15.0.0-alpha.1'; + this.v = '15.1.0-alpha.1'; this.$ = $; const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u; // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative). @@ -255,7 +255,8 @@ function Buffee($, { rows, cols, s = 4 } = {}) { f: 0, /** framecount */ ch, /** line and character height */ cw: $caret.getBoundingClientRect().width, /** computed character width */ - sub: [] /** render callbacks */ + sub: [], /** render callbacks */ + ext: [] /** registered extensions */ }; /** diff --git a/dev/changelog.txt b/dev/changelog.txt index a7e68847..8faa7493 100644 --- a/dev/changelog.txt +++ b/dev/changelog.txt @@ -1,5 +1,10 @@ * Project Devlog +** 15.1.0-alpha.1 [2026-01-12] gz: 2601 / 2.54 KB (+4), br: 2395 / 2.34 KB (+3), min: 5819 / 5.68 KB (+7) +- new: Mode.ext array tracks registered extensions in order + - Each extension pushes its name to Mode.ext on initialization + - Allows inspection of extension registration order + ** 15.0.0-alpha.1 [2026-01-12] gz: 2597 / 2.54 KB (-29), br: 2392 / 2.34 KB (-30), min: 5812 / 5.68 KB (-51) - breaking: remove automatic tab expansion from core - Buffee no longer sanitizes text - tabs/Unicode inserted as-is diff --git a/dist/buffee.min.js b/dist/buffee.min.js index b7cd7ded..fb2a1507 100644 --- a/dist/buffee.min.js +++ b/dist/buffee.min.js @@ -1 +1 @@ -function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="15.0.0-alpha.1",this.$=t;const i=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,d,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.start+e]??null],[_,(t,e)=>t.textContent=L.start+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.end?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(n?s:0)){let t=c.x;const r=n?()=>tt>0,h=n?()=>t++:()=>t--;if(i.test(e[t])){for(;r()&&i.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(n?c.y0)&&(c.x=n?0:E._[--c.y].length,n&&++c.y>L.end?L.set(c.y-L.n+1):!n&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){const e=t.split("\n");if(this.dir){const[s,n]=M.bounds(1);E.del(s.y,s.x,n.y,n.x+(this.dir>0)),E.ins(s.y,s.x,e),c.y=s.y,e.length>1?(c.y+=e.length-1,c.x=e[e.length-1].length):c.x=s.x+t.length,this.cursor()}else E.ins(c.y,c.x,e),e.length>1?(c.y+=e.length-1,o=c.x=e[e.length-1].length):o=c.x+=t.length;c.y>L.end?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins(""):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let n=e.y;n<=s.y;n++){const i=E._[n];if(t>0)E._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);E._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:n,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[]},E=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},L=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,E.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,E.end)),C(s)},get _(){return E._.slice(this.start,this.end+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(d,(L.start+L.N).toString().length)+x;_.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),n=Math.min(L.start+L.n,s.y+1);for(let e=Math.max(L.start,t.y);e=0&&is?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(f.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(f.clientHeight/a)-L.n)}).observe(f),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e)}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins("\n")},Tab:()=>{t.preventDefault(),M.dir||n?M.dent(n?-D.s:D.s):M.ins(" ".repeat(D.s))}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&M.dir?M.cursor():n&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!n&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.end));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.end?L.set(e-L.n+1):$()}else n&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins(s)):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file +function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="15.1.0-alpha.1",this.$=t;const i=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,d,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.start+e]??null],[_,(t,e)=>t.textContent=L.start+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.end?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(n?s:0)){let t=c.x;const r=n?()=>tt>0,h=n?()=>t++:()=>t--;if(i.test(e[t])){for(;r()&&i.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(n?c.y0)&&(c.x=n?0:E._[--c.y].length,n&&++c.y>L.end?L.set(c.y-L.n+1):!n&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){const e=t.split("\n");if(this.dir){const[s,n]=M.bounds(1);E.del(s.y,s.x,n.y,n.x+(this.dir>0)),E.ins(s.y,s.x,e),c.y=s.y,e.length>1?(c.y+=e.length-1,c.x=e[e.length-1].length):c.x=s.x+t.length,this.cursor()}else E.ins(c.y,c.x,e),e.length>1?(c.y+=e.length-1,o=c.x=e[e.length-1].length):o=c.x+=t.length;c.y>L.end?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins(""):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let n=e.y;n<=s.y;n++){const i=E._[n];if(t>0)E._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);E._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:n,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},L=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,E.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,E.end)),C(s)},get _(){return E._.slice(this.start,this.end+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(d,(L.start+L.N).toString().length)+x;_.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),n=Math.min(L.start+L.n,s.y+1);for(let e=Math.max(L.start,t.y);e=0&&is?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(f.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(f.clientHeight/a)-L.n)}).observe(f),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e)}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins("\n")},Tab:()=>{t.preventDefault(),M.dir||n?M.dent(n?-D.s:D.s):M.ins(" ".repeat(D.s))}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&M.dir?M.cursor():n&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!n&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.end));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.end?L.set(e-L.n+1):$()}else n&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins(s)):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file diff --git a/extensions/elementals.js b/extensions/elementals.js index 3fc8defa..9f64031a 100644 --- a/extensions/elementals.js +++ b/extensions/elementals.js @@ -366,6 +366,7 @@ function BuffeeElementals(editor) { // Attach to editor instance editor.Elementals = Elementals; + editor.Mode.ext.push('Elementals'); return editor; } diff --git a/extensions/fileloader.js b/extensions/fileloader.js index c2c6f801..fb26f810 100644 --- a/extensions/fileloader.js +++ b/extensions/fileloader.js @@ -394,6 +394,7 @@ function BuffeeFileLoader(editor) { // Attach to editor instance editor.FileLoader = FileLoader; + editor.Mode.ext.push('FileLoader'); return editor; } diff --git a/extensions/highlights.js b/extensions/highlights.js index 980acde0..18b97444 100644 --- a/extensions/highlights.js +++ b/extensions/highlights.js @@ -110,5 +110,7 @@ function BuffeeHighlights(editor) { }; editor.Highlights = Highlights; + editor.Mode.ext.push('Highlights'); + return editor; } diff --git a/extensions/history.js b/extensions/history.js index 5ee5a933..8c5db1cb 100644 --- a/extensions/history.js +++ b/extensions/history.js @@ -207,6 +207,7 @@ function BuffeeHistory(editor) { }; editor.History = History; + editor.Mode.ext.push('History'); return editor; } diff --git a/extensions/ios.js b/extensions/ios.js index e8421bd5..93bb54c1 100644 --- a/extensions/ios.js +++ b/extensions/ios.js @@ -169,6 +169,7 @@ function BuffeeIOS(editor) { // Attach to editor instance editor.iOS = iOS; + editor.Mode.ext.push('iOS'); return editor; } diff --git a/extensions/sanitize.js b/extensions/sanitize.js index 9caf70fa..1a748c54 100644 --- a/extensions/sanitize.js +++ b/extensions/sanitize.js @@ -90,6 +90,7 @@ function BuffeeSanitize(editor) { }; editor.Sanitize = Sanitize; + editor.Mode.ext.push('Sanitize'); return editor; } diff --git a/extensions/statusline.js b/extensions/statusline.js index 0aa7c2e4..fd5a66ad 100644 --- a/extensions/statusline.js +++ b/extensions/statusline.js @@ -102,5 +102,7 @@ function BuffeeStatusLine(editor, { showSelection = false } = {}) { sub.push(updateStatusLine); updateStatusLine(); // Initial population + editor.Mode.ext.push('StatusLine'); + return editor; } diff --git a/extensions/syntax.js b/extensions/syntax.js index e49ee8a4..91530f0d 100644 --- a/extensions/syntax.js +++ b/extensions/syntax.js @@ -701,6 +701,7 @@ function BuffeeSyntax(editor) { // Attach to editor instance editor.Syntax = Syntax; + editor.Mode.ext.push('Syntax'); return editor; } diff --git a/extensions/treesitter.js b/extensions/treesitter.js index 247f0e1e..572374c4 100644 --- a/extensions/treesitter.js +++ b/extensions/treesitter.js @@ -164,6 +164,7 @@ function BuffeeTreeSitter(editor, { parser, query }) { // Attach to editor instance editor.TreeSitter = TreeSitter; + editor.Mode.ext.push('TreeSitter'); return editor; } diff --git a/extensions/tui.js b/extensions/tui.js index 69ed2ab6..d2825291 100644 --- a/extensions/tui.js +++ b/extensions/tui.js @@ -326,5 +326,7 @@ function BuffeeTUI(editor) { }); editor.TUI = TUI; + editor.Mode.ext.push('TUI'); + return editor; } diff --git a/extensions/ultrahighcapacity.js b/extensions/ultrahighcapacity.js index 3c25a845..1eada6be 100644 --- a/extensions/ultrahighcapacity.js +++ b/extensions/ultrahighcapacity.js @@ -370,6 +370,7 @@ function BuffeeUltraHighCapacity(editor) { // Attach to editor instance editor.UltraHighCapacity = UltraHighCapacity; + editor.Mode.ext.push('UltraHighCapacity'); return editor; } diff --git a/extensions/undotree.js b/extensions/undotree.js index 446668ed..5eea14a8 100644 --- a/extensions/undotree.js +++ b/extensions/undotree.js @@ -356,6 +356,8 @@ function BuffeeUndoTree(editor) { set: (v) => { _lastOpTime = v; } }); + editor.Mode.ext.push('UndoTree'); + return editor; } diff --git a/test/lib/test-extensions.js b/test/lib/test-extensions.js index 3f9d910d..d5cdc830 100644 --- a/test/lib/test-extensions.js +++ b/test/lib/test-extensions.js @@ -1131,6 +1131,183 @@ function defineExtensionTests() { } }); }); + + // ===== EXTENSION REGISTRATION TESTS ===== + extRunner.describe('Extension Registration', () => { + extRunner.it('Mode.ext starts as empty array', () => { + const { editor, cleanup } = createTestEditor(); + try { + assertTrue(Array.isArray(editor.Mode.ext), 'Mode.ext should be an array'); + assertEqual(editor.Mode.ext.length, 0, 'Mode.ext should be empty initially'); + } finally { + cleanup(); + } + }); + + extRunner.it('History registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeHistory(editor); + assertTrue(editor.Mode.ext.includes('History'), 'Mode.ext should include History'); + } finally { + cleanup(); + } + }); + + extRunner.it('Sanitize registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeSanitize(editor); + assertTrue(editor.Mode.ext.includes('Sanitize'), 'Mode.ext should include Sanitize'); + } finally { + cleanup(); + } + }); + + extRunner.it('Syntax registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeSyntax(editor); + assertTrue(editor.Mode.ext.includes('Syntax'), 'Mode.ext should include Syntax'); + } finally { + cleanup(); + } + }); + + extRunner.it('StatusLine registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeStatusLine(editor); + assertTrue(editor.Mode.ext.includes('StatusLine'), 'Mode.ext should include StatusLine'); + } finally { + cleanup(); + } + }); + + extRunner.it('Elementals registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeElementals(editor); + assertTrue(editor.Mode.ext.includes('Elementals'), 'Mode.ext should include Elementals'); + } finally { + cleanup(); + } + }); + + extRunner.it('Highlights registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeHighlights(editor); + assertTrue(editor.Mode.ext.includes('Highlights'), 'Mode.ext should include Highlights'); + } finally { + cleanup(); + } + }); + + extRunner.it('TUI registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeTUI(editor); + assertTrue(editor.Mode.ext.includes('TUI'), 'Mode.ext should include TUI'); + } finally { + cleanup(); + } + }); + + extRunner.it('FileLoader registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeFileLoader(editor); + assertTrue(editor.Mode.ext.includes('FileLoader'), 'Mode.ext should include FileLoader'); + } finally { + cleanup(); + } + }); + + extRunner.it('UltraHighCapacity registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeUltraHighCapacity(editor); + assertTrue(editor.Mode.ext.includes('UltraHighCapacity'), 'Mode.ext should include UltraHighCapacity'); + } finally { + cleanup(); + } + }); + + extRunner.it('UndoTree registers itself', () => { + const { editor, cleanup } = createTestEditor(); + try { + BuffeeUndoTree(editor); + assertTrue(editor.Mode.ext.includes('UndoTree'), 'Mode.ext should include UndoTree'); + } finally { + cleanup(); + } + }); + + extRunner.it('chained extensions register in order', () => { + const { editor, cleanup } = createTestEditor(); + try { + // Chain multiple extensions + BuffeeHistory(editor); + BuffeeSanitize(editor); + BuffeeSyntax(editor); + BuffeeStatusLine(editor); + BuffeeElementals(editor); + + // Verify registration order + assertDeepEqual(editor.Mode.ext, [ + 'History', + 'Sanitize', + 'Syntax', + 'StatusLine', + 'Elementals' + ], 'Extensions should be registered in order'); + } finally { + cleanup(); + } + }); + + extRunner.it('decorator pattern preserves registration order', () => { + const { editor, cleanup } = createTestEditor(); + try { + // Use decorator pattern (nested calls) + const decorated = BuffeeHighlights( + BuffeeTUI( + BuffeeFileLoader( + BuffeeHistory(editor) + ) + ) + ); + + // Verify registration order (innermost first) + assertDeepEqual(decorated.Mode.ext, [ + 'History', + 'FileLoader', + 'TUI', + 'Highlights' + ], 'Decorator pattern should register innermost first'); + } finally { + cleanup(); + } + }); + + extRunner.it('reduce pattern preserves registration order', () => { + const { editor, cleanup } = createTestEditor(); + try { + // Use reduce pattern + const extensions = [BuffeeHistory, BuffeeSanitize, BuffeeUndoTree]; + extensions.reduce((ed, ext) => ext(ed), editor); + + assertDeepEqual(editor.Mode.ext, [ + 'History', + 'Sanitize', + 'UndoTree' + ], 'Reduce pattern should register in array order'); + } finally { + cleanup(); + } + }); + }); } // =========================================== From 3f9f436870acd936eea5bd28c2dd822ee2015e88 Mon Sep 17 00:00:00 2001 From: varrockbank Date: Mon, 12 Jan 2026 23:53:19 +0100 Subject: [PATCH 03/50] =?UTF-8?q?breaking:=20Span.ins=20now=20takes=20arra?= =?UTF-8?q?y=20of=20lines=20instead=20of=20string=20-=20`Span.ins('text')`?= =?UTF-8?q?=20=E2=86=92=20`Span.ins(['text'])`=20-=20`Span.ins('line1\nlin?= =?UTF-8?q?e2')`=20=E2=86=92=20`Span.ins(['line1',=20'line2'])`=20-=20Clie?= =?UTF-8?q?nt=20handles=20newline=20splitting,=20not=20buffee=20core=20-?= =?UTF-8?q?=20FileLoader:=20removed=20expandTabs=20from=20appendLines=20he?= =?UTF-8?q?lper=20-=20sample-loader.html:=20wrap=20with=20BuffeeSanitize?= =?UTF-8?q?=20for=20tab=20handling=20-=20Update=20API=20docs=20for=20Span.?= =?UTF-8?q?ins=20and=20Mode.ext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- buffee.js | 23 ++++++------- dev/changelog.txt | 7 ++++ dist/buffee.min.js | 2 +- docs/api.md | 5 +-- extensions/fileloader.js | 4 +-- samples/sample-loader.html | 5 +-- test/lib/test-extensions.js | 62 +++++++++++++++++----------------- test/specs/spec-regression.dsl | 2 +- web/extensions.html | 5 ++- 9 files changed, 62 insertions(+), 53 deletions(-) diff --git a/buffee.js b/buffee.js index 463eb26d..3b42d084 100644 --- a/buffee.js +++ b/buffee.js @@ -17,7 +17,7 @@ * editor.Model.s = 'Hello, World!'; */ function Buffee($, { rows, cols, s = 4 } = {}) { - this.v = '15.1.0-alpha.1'; + this.v = '15.2.0-alpha.1'; this.$ = $; const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u; // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative). @@ -159,11 +159,10 @@ function Buffee($, { rows, cols, s = 4 } = {}) { }, /** - * Inserts a string at cursor position, replacing any selection. - * @param {string} s - String to insert + * Inserts lines at cursor position, replacing any selection. + * @param {string[]} lines - Array of lines to insert */ - ins(s) { - const lines = s.split('\n'); + ins(lines) { if (this.dir) { const [first, second] = Span.bounds(1); Model.del(first.y, first.x, second.y, second.x + (this.dir > 0)); @@ -174,7 +173,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { if (lines.length > 1) { head.y += lines.length - 1; head.x = lines[lines.length - 1].length; - } else head.x = first.x + s.length; + } else head.x = first.x + (lines[0]?.length || 0); this.cursor(); } else { @@ -184,7 +183,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { if (lines.length > 1) { head.y += lines.length - 1; maxCol = head.x = lines[lines.length - 1].length; - } else maxCol = head.x += s.length; + } else maxCol = head.x += lines[0]?.length || 0; } if (head.y > View.end) View.set(head.y - View.n + 1); else render(); @@ -192,7 +191,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { /** Deletes the character before cursor or the current selection. */ del() { - if (this.dir) this.ins(''); + if (this.dir) this.ins(['']); else if (head.x > 0) { // Delete character before cursor Model.del(head.y, head.x - 1, head.y, head.x); @@ -410,7 +409,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { $lines.addEventListener('paste', e => { e.preventDefault(); // stop browser from inserting raw clipboard text const text = e.clipboardData.getData('text/plain'); - if (text) Span.ins(text); + if (text) Span.ins(text.split('\n')); }); // Triggered by a keydown paste event. a copy event handler can read the clipboard // by the standard security model. Meanwhile, we don't have to make the editor "selectable". @@ -438,10 +437,10 @@ function Buffee($, { rows, cols, s = 4 } = {}) { z: () => { e.preventDefault(); if (this.History) this.History[sh ? 'redo' : 'undo'](); }, }, special = { Backspace: () => { Span.del() }, - Enter: () => { Span.ins('\n') } , + Enter: () => { Span.ins(['', '']) } , Tab: () => { e.preventDefault(); - (Span.dir || sh) ? Span.dent(sh ? -Mode.s : Mode.s) : Span.ins(' '.repeat(Mode.s)); + (Span.dir || sh) ? Span.dent(sh ? -Mode.s : Mode.s) : Span.ins([' '.repeat(Mode.s)]); }, }; @@ -481,7 +480,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { if (cmd) metaKeys[k.toLowerCase()]?.(); else if (Mode.i > 0) { k === ' ' && e.preventDefault(); - Span.ins(k); + Span.ins([k]); } } else if (special[k] && Mode.i >= 1) { special[k](); } }); diff --git a/dev/changelog.txt b/dev/changelog.txt index 8faa7493..d892be69 100644 --- a/dev/changelog.txt +++ b/dev/changelog.txt @@ -1,5 +1,12 @@ * Project Devlog +** 15.2.0-alpha.1 [2026-01-12] gz: 2600 / 2.54 KB (-1), br: 2402 / 2.35 KB (+7), min: 5834 / 5.70 KB (15) +- breaking: Span.ins now takes array of lines instead of string + - `Span.ins('text')` → `Span.ins(['text'])` + - `Span.ins('line1\nline2')` → `Span.ins(['line1', 'line2'])` + - Client handles newline splitting, not buffee core +- FileLoader: remove expandTabs from appendLines helper + ** 15.1.0-alpha.1 [2026-01-12] gz: 2601 / 2.54 KB (+4), br: 2395 / 2.34 KB (+3), min: 5819 / 5.68 KB (+7) - new: Mode.ext array tracks registered extensions in order - Each extension pushes its name to Mode.ext on initialization diff --git a/dist/buffee.min.js b/dist/buffee.min.js index fb2a1507..e6a2f013 100644 --- a/dist/buffee.min.js +++ b/dist/buffee.min.js @@ -1 +1 @@ -function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="15.1.0-alpha.1",this.$=t;const i=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,d,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.start+e]??null],[_,(t,e)=>t.textContent=L.start+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.end?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(n?s:0)){let t=c.x;const r=n?()=>tt>0,h=n?()=>t++:()=>t--;if(i.test(e[t])){for(;r()&&i.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(n?c.y0)&&(c.x=n?0:E._[--c.y].length,n&&++c.y>L.end?L.set(c.y-L.n+1):!n&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){const e=t.split("\n");if(this.dir){const[s,n]=M.bounds(1);E.del(s.y,s.x,n.y,n.x+(this.dir>0)),E.ins(s.y,s.x,e),c.y=s.y,e.length>1?(c.y+=e.length-1,c.x=e[e.length-1].length):c.x=s.x+t.length,this.cursor()}else E.ins(c.y,c.x,e),e.length>1?(c.y+=e.length-1,o=c.x=e[e.length-1].length):o=c.x+=t.length;c.y>L.end?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins(""):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let n=e.y;n<=s.y;n++){const i=E._[n];if(t>0)E._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);E._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:n,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},L=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,E.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,E.end)),C(s)},get _(){return E._.slice(this.start,this.end+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(d,(L.start+L.N).toString().length)+x;_.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),n=Math.min(L.start+L.n,s.y+1);for(let e=Math.max(L.start,t.y);e=0&&is?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(f.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(f.clientHeight/a)-L.n)}).observe(f),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e)}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins("\n")},Tab:()=>{t.preventDefault(),M.dir||n?M.dent(n?-D.s:D.s):M.ins(" ".repeat(D.s))}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&M.dir?M.cursor():n&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!n&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.end));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.end?L.set(e-L.n+1):$()}else n&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins(s)):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file +function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="15.2.0-alpha.1",this.$=t;const i=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,d,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.start+e]??null],[_,(t,e)=>t.textContent=L.start+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.end?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(n?s:0)){let t=c.x;const r=n?()=>tt>0,h=n?()=>t++:()=>t--;if(i.test(e[t])){for(;r()&&i.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(n?c.y0)&&(c.x=n?0:E._[--c.y].length,n&&++c.y>L.end?L.set(c.y-L.n+1):!n&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.end?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let n=e.y;n<=s.y;n++){const i=E._[n];if(t>0)E._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);E._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:n,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},L=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,E.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,E.end)),C(s)},get _(){return E._.slice(this.start,this.end+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(d,(L.start+L.N).toString().length)+x;_.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),n=Math.min(L.start+L.n,s.y+1);for(let e=Math.max(L.start,t.y);e=0&&is?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(f.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(f.clientHeight/a)-L.n)}).observe(f),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||n?M.dent(n?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&M.dir?M.cursor():n&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!n&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.end));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.end?L.set(e-L.n+1):$()}else n&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 617d60b9..a9bc6174 100644 --- a/docs/api.md +++ b/docs/api.md @@ -83,7 +83,7 @@ Span .mvLn(value) // move to '\n' : 1 end , 0 front .mvW(value) // move by words : 1 next , -1 prev .del() // delete text Span-wise delete - .ins(text) // insert text Span-wise insert + .ins(lines) // insert lines (string[]) Span-wise insert .select() // make selection .cursor() // make cursor .dent(value) // indent or unindent : 1 indent, -1 unident @@ -96,13 +96,14 @@ Span Editor state and configuration. ```javascript -mode +Mode .s // Tab width .i // Edit mode: 1=write, 0=navigate, -1=read .f // Render frame counter .ch // Line height in pixels .cw // Character width in pixels .sub // subscriptions for render callback + .ext // Array of registered extension names (in order) ``` --- diff --git a/extensions/fileloader.js b/extensions/fileloader.js index fb26f810..aed229dc 100644 --- a/extensions/fileloader.js +++ b/extensions/fileloader.js @@ -17,9 +17,7 @@ function BuffeeFileLoader(editor) { const { Model, Mode, render, $ } = editor; function appendLines(newLines, skipRender = false) { - const spaces = Mode.s; - const expandTabs = s => spaces ? s.replace(/\t/g, ' '.repeat(spaces)) : s; - Model._.push(...newLines.map(expandTabs)); + Model._.push(...newLines); if (!skipRender) render(); } diff --git a/samples/sample-loader.html b/samples/sample-loader.html index 518c6225..99812529 100644 --- a/samples/sample-loader.html +++ b/samples/sample-loader.html @@ -5,6 +5,7 @@ buffee - Ultra High Capacity + @@ -152,9 +153,9 @@

Logs:

logs._render(true); } - const editor = BuffeeUltraHighCapacity(BuffeeFileLoader(BuffeeStatusLine( + const editor = BuffeeUltraHighCapacity(BuffeeFileLoader(BuffeeSanitize(BuffeeStatusLine( new Buffee(editorEl, { rows: 10, logger: log }) - ))); + )))); editor.Model.s = 'Select a file to load...'; editorEl.focus(); diff --git a/test/lib/test-extensions.js b/test/lib/test-extensions.js index d5cdc830..9226fa97 100644 --- a/test/lib/test-extensions.js +++ b/test/lib/test-extensions.js @@ -560,7 +560,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('A'); + editor.Span.ins(['A']); assertEqual(editor.Model._[0], 'A', 'Should have "A"'); editor.History.undo(); assertEqual(editor.Model._[0], '', 'Should be empty after undo'); @@ -573,7 +573,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('A'); + editor.Span.ins(['A']); editor.History.undo(); assertEqual(editor.Model._[0], '', 'Should be empty after undo'); editor.History.redo(); @@ -587,9 +587,9 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('A'); - editor.Span.ins('B'); - editor.Span.ins('C'); + editor.Span.ins(['A']); + editor.Span.ins(['B']); + editor.Span.ins(['C']); assertEqual(editor.Model._[0], 'ABC', 'Should have "ABC"'); editor.History.undo(); assertEqual(editor.Model._[0], '', 'Should be empty after single undo (coalesced)'); @@ -602,7 +602,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('AB'); + editor.Span.ins(['AB']); // Wait to break coalescing editor.History._lastOpTime = 0; editor.Span.del(); @@ -618,11 +618,11 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('Hello'); + editor.Span.ins(['Hello']); editor.History._lastOpTime = 0; - editor.Span.ins('\n'); + editor.Span.ins(['', '']); editor.History._lastOpTime = 0; - editor.Span.ins('World'); + editor.Span.ins(['World']); assertEqual(editor.Model._.length, 2, 'Should have 2 lines'); editor.History.undo(); assertEqual(editor.Model._[1], '', 'Second line should be empty after undo'); @@ -637,10 +637,10 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('A'); + editor.Span.ins(['A']); editor.History.undo(); assertEqual(editor.History.redoStack.length, 1, 'Should have 1 redo item'); - editor.Span.ins('B'); + editor.Span.ins(['B']); assertEqual(editor.History.redoStack.length, 0, 'Redo stack should be cleared'); } finally { cleanup(); @@ -651,7 +651,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('Hello'); + editor.Span.ins(['Hello']); assertEqual(editor.Span.bounds()[0].x, 5, 'Cursor should be at col 5'); editor.History.undo(); assertEqual(editor.Span.bounds()[0].x, 0, 'Cursor should be at col 0 after undo'); @@ -664,7 +664,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('AB'); + editor.Span.ins(['AB']); editor.History.undo(); assertEqual(editor.Span.bounds()[0].x, 0, 'Cursor should be at col 0 after undo'); editor.History.redo(); @@ -678,8 +678,8 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHistory(editor); - editor.Span.ins('A'); - editor.Span.ins('B'); + editor.Span.ins(['A']); + editor.Span.ins(['B']); assertTrue(editor.History.undoStack.length > 0, 'Should have undo items'); editor.History.clear(); assertEqual(editor.History.undoStack.length, 0, 'Undo stack should be empty'); @@ -695,7 +695,7 @@ function defineExtensionTests() { try { BuffeeHistory(editor); // Type "Hello World" - editor.Span.ins('Hello World'); + editor.Span.ins(['Hello World']); editor.History._lastOpTime = 0; // Select "Hello" (first 5 chars) @@ -705,7 +705,7 @@ function defineExtensionTests() { tail1.x = 5; // Replace selection with "Hi" - editor.Span.ins('Hi'); + editor.Span.ins(['Hi']); assertEqual(editor.Model._[0], 'Hi World', 'Should have replaced "Hello" with "Hi"'); // Single undo should restore "Hello World" @@ -722,7 +722,7 @@ function defineExtensionTests() { try { BuffeeHistory(editor); // Type some text - editor.Span.ins('Hello World'); + editor.Span.ins(['Hello World']); editor.History._lastOpTime = 0; // Make a selection (this changes head to detachedHead internally) @@ -766,7 +766,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeUndoTree(editor); - editor.Span.ins('A'); + editor.Span.ins(['A']); assertEqual(editor.Model._[0], 'A', 'Should have "A"'); editor.UndoTree.undo(); assertEqual(editor.Model._[0], '', 'Should be empty after undo'); @@ -781,12 +781,12 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeUndoTree(editor); - editor.Span.ins('A'); + editor.Span.ins(['A']); editor.UndoTree._lastOpTime = 0; // Break coalescing editor.UndoTree.undo(); // Make a new edit - should create branch, not discard - editor.Span.ins('B'); + editor.Span.ins(['B']); // Root should have 2 children (branches) const tree = editor.UndoTree.getTree(); @@ -802,12 +802,12 @@ function defineExtensionTests() { BuffeeUndoTree(editor); // Create first branch - editor.Span.ins('A'); + editor.Span.ins(['A']); editor.UndoTree._lastOpTime = 0; editor.UndoTree.undo(); // Create second branch - editor.Span.ins('B'); + editor.Span.ins(['B']); editor.UndoTree._lastOpTime = 0; assertEqual(editor.Model._[0], 'B', 'Should be on B branch'); @@ -830,10 +830,10 @@ function defineExtensionTests() { try { BuffeeUndoTree(editor); - editor.Span.ins('A'); + editor.Span.ins(['A']); editor.UndoTree._lastOpTime = 0; editor.UndoTree.undo(); - editor.Span.ins('B'); + editor.Span.ins(['B']); editor.UndoTree._lastOpTime = 0; editor.UndoTree.undo(); @@ -852,12 +852,12 @@ function defineExtensionTests() { BuffeeUndoTree(editor); // Create: root -> A -> B -> C - editor.Span.ins('A'); + editor.Span.ins(['A']); editor.UndoTree._lastOpTime = 0; const nodeAId = editor.UndoTree.current.id; - editor.Span.ins('B'); + editor.Span.ins(['B']); editor.UndoTree._lastOpTime = 0; - editor.Span.ins('C'); + editor.Span.ins(['C']); editor.UndoTree._lastOpTime = 0; assertEqual(editor.Model._[0], 'ABC', 'Should have ABC'); @@ -874,7 +874,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeUndoTree(editor); - editor.Span.ins('X'); + editor.Span.ins(['X']); const tree = editor.UndoTree.getTree(); assertEqual(tree.id, 0, 'Root should have id 0'); @@ -890,8 +890,8 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeUndoTree(editor); - editor.Span.ins('A'); - editor.Span.ins('B'); + editor.Span.ins(['A']); + editor.Span.ins(['B']); assertTrue(editor.UndoTree.canUndo, 'Should be able to undo'); editor.UndoTree.clear(); diff --git a/test/specs/spec-regression.dsl b/test/specs/spec-regression.dsl index bcc8438f..af3fb7e3 100644 --- a/test/specs/spec-regression.dsl +++ b/test/specs/spec-regression.dsl @@ -22,7 +22,7 @@ expect(5).toBe(5); ### Replacing selection with multi-char text should position cursor correctly TYPE "Hello world" left 5 times with shift -fixture.editor.Span.ins('REPLACED'); +fixture.editor.Span.ins(['REPLACED']); expect(fixture).toHaveLines('Hello REPLACED'); EXPECT cursor at 0,14 diff --git a/web/extensions.html b/web/extensions.html index 4af01e27..f913a98c 100644 --- a/web/extensions.html +++ b/web/extensions.html @@ -64,7 +64,10 @@

TUI Legacy

FileLoader

extensions/fileloader.js

Multiple file loading strategies optimized for different file sizes. Choose the right loader based on your file size and memory constraints.

-
const editor = BuffeeFileLoader(Buffee(container, config))
+  

Note: FileLoader does not sanitize text. If files may contain tabs, wrap with BuffeeSanitize:

+
// With tab sanitization
+const editor = BuffeeFileLoader(BuffeeSanitize(Buffee(container, config)))
+
 await editor.FileLoader.naiveLoad(file)           // <10M lines
 await editor.FileLoader.chunkedBlobLoad(file)     // <70M lines
 await editor.FileLoader.chunkedFileReaderLoad(file)

From 7927ea2909ae79951fb61c1f3fa5edc648bda349 Mon Sep 17 00:00:00 2001
From: varrockbank 
Date: Tue, 13 Jan 2026 06:57:00 +0100
Subject: [PATCH 04/50] refactor: rename Model.end to Model.last,
 View.start/end to View.first/last

---
 buffee.js                       | 52 ++++++++++++++++-----------------
 dev/changelog.txt               |  5 ++++
 dist/buffee.min.js              |  2 +-
 docs/api.md                     | 34 +++++++++------------
 extensions/elementals.js        |  2 +-
 extensions/ios.js               |  6 ++--
 extensions/statusline.js        |  2 +-
 extensions/ultrahighcapacity.js | 12 ++++----
 8 files changed, 56 insertions(+), 59 deletions(-)

diff --git a/buffee.js b/buffee.js
index 3b42d084..18d245bc 100644
--- a/buffee.js
+++ b/buffee.js
@@ -17,7 +17,7 @@
  * editor.Model.s = 'Hello, World!';
  */
 function Buffee($, { rows, cols, s = 4 } = {}) {
-  this.v = '15.2.0-alpha.1';
+  this.v = '15.3.0-alpha.1';
   this.$ = $;
   const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u;
   // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative).
@@ -39,8 +39,8 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
 
   // [array, fragment, parent, updateFn]
   const viewportLayers = [
-    [$ztxt, (el, i) => el.textContent = Model._[View.start + i] ?? null],
-    [$rail, (el, i) => el.textContent = View.start + i + 1],
+    [$ztxt, (el, i) => el.textContent = Model._[View.first + i] ?? null],
+    [$rail, (el, i) => el.textContent = View.first + i + 1],
     [$zsel, (el) => el.style.width = 0]
   ].map(([p, fn]) => [[], document.createDocumentFragment(), p, fn]);
 
@@ -63,11 +63,11 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
      * @param {boolean} [toEdge] - If truthy, go to edge (start if down, end if up) and update maxCol
      */
     mvY(dir, toEdge) {
-      if (dir > 0 ? head.y < Model.end : head.y > 0) {
+      if (dir > 0 ? head.y < Model.last : head.y > 0) {
         const len = Model._[dir > 0 ? ++head.y : --head.y].length;
         head.x = toEdge ? (dir > 0 ? 0 : len) : Math.min(maxCol, len);
         if (toEdge) maxCol = head.x;
-        if (head.y < View.start || head.y > View.end) View.set(dir > 0 ? head.y - View.n + 1 : head.y);
+        if (head.y < View.first || head.y > View.last) View.set(dir > 0 ? head.y - View.n + 1 : head.y);
         else render();
       }
     },
@@ -79,7 +79,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
     mvX(dir) {
       const right = dir > 0;
       if (right ? head.x < Model._[head.y].length : head.x) { maxCol = right ? ++head.x : --head.x; render(); }
-      else if (right ? head.y < Model.end : head.y) this.mvY(dir, 1);
+      else if (right ? head.y < Model.last : head.y) this.mvY(dir, 1);
     },
 
     /**
@@ -107,11 +107,11 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
         else { const c = s[j]; step(); while (ok() && s[j] === c) step(); }
         head.x = j;
         render();
-      } else if (fwd ? head.y < Model.end : head.y > 0) {
+      } else if (fwd ? head.y < Model.last : head.y > 0) {
         // At edge - move to adjacent line
         head.x = fwd ? 0 : Model._[--head.y].length;
-        if (fwd && ++head.y > View.end) View.set(head.y - View.n + 1);
-        else if (!fwd && head.y < View.start) View.set(head.y);
+        if (fwd && ++head.y > View.last) View.set(head.y - View.n + 1);
+        else if (!fwd && head.y < View.first) View.set(head.y);
         else render();
       }
     },
@@ -134,7 +134,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
       if(left.y === right.y) {
         const text  = Model._[left.y];
         const slice = text.slice(left.x, right.x + (this.dir > 0));
-        return right.x >= text.length && left.y < Model.end ? [slice, ''] : [slice];
+        return right.x >= text.length && left.y < Model.last ? [slice, ''] : [slice];
       }
       return [
         Model._[left.y].slice(left.x),
@@ -185,7 +185,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
                maxCol = head.x  = lines[lines.length - 1].length;
         } else maxCol = head.x += lines[0]?.length || 0;
       }
-      if (head.y > View.end) View.set(head.y - View.n + 1);
+      if (head.y > View.last) View.set(head.y - View.n + 1);
       else render();
     },
 
@@ -201,7 +201,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
         // At start of line - delete newline (join with previous line)
         head.x = Model._[head.y - 1].length;
         Model.del(head.y - 1, head.x, head.y, 0);
-        if (--head.y < View.start) View.set(head.y);
+        if (--head.y < View.first) View.set(head.y);
         else render();
       }
     },
@@ -270,7 +270,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
      * Index of the last line in the document.
      * @returns {number} Zero-based index of the last line
      */
-    get end() { return this._.length - 1 },
+    get last() { return this._.length - 1 },
 
     /**
      * Sets the document content from a string. Splits on newlines.
@@ -313,7 +313,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
    */
   const View = this.View = {
     /** @type {number} Index of the first visible line (0-indexed) */
-    start: 0,
+    first: 0,
     /** @type {number} Number of visible lines */
     n: 0,
     /** @type {number} Number of DOM line containers. +1 if auto-fit (no rows specified) */
@@ -323,17 +323,17 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
      * Index of the last visible line.
      * @returns {number} Index of the last line in the viewport
      */
-    get end() { return Math.min(this.start + this.n - 1, Model.end); },
+    get last() { return Math.min(this.first + this.n - 1, Model.last); },
 
     /**
      * Sets the viewport position and optionally size.
      * @param {number} start - Line index to start at (0-indexed)
      * @param {number} [size] - Number of lines to display (optional)
      */
-    set(start, size = this.n) {
+    set(first, size = this.n) {
       const delta = size - this.n;
       this.n = size;
-      this.start = Math.max(0, Math.min(start, Model.end));
+      this.first = Math.max(0, Math.min(first, Model.last));
       RENDER(delta);
     },
 
@@ -341,7 +341,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
      * Gets the lines currently visible in the viewport.
      * @returns {string[]} Array of visible line contents
      */
-    get _() { return Model._.slice(this.start, this.end + 1); }
+    get _() { return Model._.slice(this.first, this.last + 1); }
   };
 
   // Add / remove lines, selections, rails as row changes
@@ -353,7 +353,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
       for (d = delta; d < 0; d++) viewportLayers.forEach(([a]) => a.pop()?.remove());
     }
     if ($rail) {
-      const railCols = Math.max(railInit, (View.start + View.N).toString().length) + railPad;
+      const railCols = Math.max(railInit, (View.first + View.N).toString().length) + railPad;
       $rail.style.width = railCols + 'ch';
       if (cols) $pane.style.width = `calc(${railCols + cols}ch + ${padding * 4}px)`;
     }
@@ -373,15 +373,15 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
     if(Mode.i >= 0) {
       // Sels 
       const [firstEdge, secondEdge] = Span.bounds(1);
-      const rEnd = Math.min(View.start + View.n, secondEdge.y + 1);
-      for (let r = Math.max(View.start, firstEdge.y); r < rEnd; r++) {
-        const f = r === firstEdge.y, l = r === secondEdge.y, n = Model._[r].length, {style} = viewportLayers[2][0][r - View.start];
+      const rEnd = Math.min(View.first + View.n, secondEdge.y + 1);
+      for (let r = Math.max(View.first, firstEdge.y); r < rEnd; r++) {
+        const f = r === firstEdge.y, l = r === secondEdge.y, n = Model._[r].length, {style} = viewportLayers[2][0][r - View.first];
         style.left = (f ? firstEdge.x : 0) + 'ch';
         style.width = (f && l ? secondEdge.x - firstEdge.x : f ? n - firstEdge.x + 1 : l ? Math.min(secondEdge.x, n) : n + 1) + 'ch';
       }
 
       // Cursor
-      const headViewRow = head.y - View.start;
+      const headViewRow = head.y - View.first;
       if (headViewRow >= 0 && headViewRow < View.n) {
         $caret.style.top = headViewRow * ch + 'px';
         cursorLeft = head.x;
@@ -462,14 +462,14 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
         } else {
           const edge = Span.bounds(1)[direction > 0 | 0];
           // edge.y is already absolute
-          const targetAbsRow = Math.max(0, Math.min(edge.y + direction, Model.end));
+          const targetAbsRow = Math.max(0, Math.min(edge.y + direction, Model.last));
 
           maxCol = Math.min(edge.x, Model._[targetAbsRow].length);
           Span.cursor({ y: targetAbsRow, x: maxCol});
 
           // Scroll viewport if target is outside visible area
-          if (targetAbsRow < View.start) View.set(targetAbsRow);
-          else if (targetAbsRow > View.end) View.set(targetAbsRow - View.n + 1);
+          if (targetAbsRow < View.first) View.set(targetAbsRow);
+          else if (targetAbsRow > View.last) View.set(targetAbsRow - View.n + 1);
           else render();
         }
       } else { // no meta key.
diff --git a/dev/changelog.txt b/dev/changelog.txt
index d892be69..3d147d60 100644
--- a/dev/changelog.txt
+++ b/dev/changelog.txt
@@ -1,5 +1,10 @@
 * Project Devlog
 
+** 15.3.0-alpha.1 [2026-01-13]  gz: 2606 / 2.54 KB (+6), br: 2405 / 2.35 KB (+3), min: 5848 / 5.71 KB (14)
+- refactor: rename Model.end to Model.last
+- refactor: rename View.start to View.first
+- refactor: rename View.end to View.last
+
 ** 15.2.0-alpha.1 [2026-01-12]  gz: 2600 / 2.54 KB (-1), br: 2402 / 2.35 KB (+7), min: 5834 / 5.70 KB (15)
 - breaking: Span.ins now takes array of lines instead of string
   - `Span.ins('text')` → `Span.ins(['text'])`
diff --git a/dist/buffee.min.js b/dist/buffee.min.js
index e6a2f013..120f977a 100644
--- a/dist/buffee.min.js
+++ b/dist/buffee.min.js
@@ -1 +1 @@
-function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="15.2.0-alpha.1",this.$=t;const i=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,d,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.start+e]??null],[_,(t,e)=>t.textContent=L.start+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.end?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(n?s:0)){let t=c.x;const r=n?()=>tt>0,h=n?()=>t++:()=>t--;if(i.test(e[t])){for(;r()&&i.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(n?c.y0)&&(c.x=n?0:E._[--c.y].length,n&&++c.y>L.end?L.set(c.y-L.n+1):!n&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.end?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let n=e.y;n<=s.y;n++){const i=E._[n];if(t>0)E._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);E._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:n,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},L=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,E.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,E.end)),C(s)},get _(){return E._.slice(this.start,this.end+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(d,(L.start+L.N).toString().length)+x;_.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),n=Math.min(L.start+L.n,s.y+1);for(let e=Math.max(L.start,t.y);e=0&&is?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(f.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(f.clientHeight/a)-L.n)}).observe(f),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||n?M.dent(n?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&M.dir?M.cursor():n&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!n&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.end));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.end?L.set(e-L.n+1):$()}else n&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})}
\ No newline at end of file
+function Buffee(t,{rows:e,cols:s,s:i=4}={}){this.v="15.3.0-alpha.1",this.$=t;const n=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,f,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[d,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.first+e]??null],[_,(t,e)=>t.textContent=L.first+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.last?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(i?s:0)){let t=c.x;const r=i?()=>tt>0,h=i?()=>t++:()=>t--;if(n.test(e[t])){for(;r()&&n.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(i?c.y0)&&(c.x=i?0:E._[--c.y].length,i&&++c.y>L.last?L.set(c.y-L.n+1):!i&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.last?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let i=e.y;i<=s.y;i++){const n=E._[i];if(t>0)E._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);E._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:i,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get last(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},L=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,E.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,E.last)),C(s)},get _(){return E._.slice(this.first,this.last+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(f,(L.first+L.N).toString().length)+x;_.style.width=t+"ch",s&&(d.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),i=Math.min(L.first+L.n,s.y+1);for(let e=Math.max(L.first,t.y);e=0&&ns?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(d.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(d.clientHeight/a)-L.n)}).observe(d),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||i?M.dent(i?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&M.dir?M.cursor():i&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!i&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.last));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.last?L.set(e-L.n+1):$()}else i&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})}
\ No newline at end of file
diff --git a/docs/api.md b/docs/api.md
index a9bc6174..f1f26f09 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -26,8 +26,6 @@ Buffee does **not** sanitize text. Content set via `Model.s` or `Span.ins()` is
 
 Note: The keyboard controller converts Tab key presses to spaces—only programmatic content is affected.
 
----
-
 ## Top-level properties
 
 ```javascript
@@ -48,31 +46,31 @@ editor
 Model
   ._    // Array of text lines, without '\n
   .s    // Set content (string with \n)
-  .end  // Last line index
+  .last // Index of last line of Model
   .ins  // primitive insert
   .del  // primitive del
 ```
 
----
-
 ## View (`editor.View`)
 
+The paradigm is to define first and size, but last is derived. An alternative implementation
+was first and last, but size is derived. The latter's API appears symmetrical but it was not
+as intuitive and the implementation uglier.
+
 ```javascript
 View
-  ._            // subset of model lines in view 
-  .start        // First visible line index
-  .end          // Last visible line index
-  .n            // Number of logical lines
-  .N            // Number of rendered lines
-  .set(line)    // Scroll to line
-  .set(line, n) // Scroll to line, show n lines
+  .first         // Model index of first line of viewport 
+  .last          // Model index of last line viewport 
+  .n             // Viewport size - number of lines (settable)
+  .N             // Number of DOM containers (n + 1 if auto-fit)
+  .set(first)    // Scroll to line, keep current size
+  .set(first, n) // Scroll to line with new size
+  ._             // Visible lines array (derived: Model._.slice(first, last + 1))
 ```
 
----
-
 ## Span (`editor.Span`)
 
-Cursor and selection management.
+A continuous text span from a starting and end coordinate. 
 
 ```javascript 
 Span
@@ -89,12 +87,8 @@ Span
   .dent(value)   // indent or unindent : 1 indent, -1 unident
 ```
 
----
-
 ## Mode (`editor.Mode`)
 
-Editor state and configuration.
-
 ```javascript
 Mode
   .s            // Tab width
@@ -106,8 +100,6 @@ Mode
   .ext          // Array of registered extension names (in order)
 ```
 
----
-
 ## Extension API
 
 For building extensions:
diff --git a/extensions/elementals.js b/extensions/elementals.js
index 9f64031a..781e1ed6 100644
--- a/extensions/elementals.js
+++ b/extensions/elementals.js
@@ -51,7 +51,7 @@ function BuffeeElementals(editor) {
    */
   function updatePositions() {
     for (const el of elements) {
-      const viewportRow = el.row - View.start;
+      const viewportRow = el.row - View.first;
       if (viewportRow >= 0 && viewportRow < View.n) {
         el.$container.style.top = viewportRow * lineHeight + 'px';
         el.$container.style.display = '';
diff --git a/extensions/ios.js b/extensions/ios.js
index 93bb54c1..62673bf6 100644
--- a/extensions/ios.js
+++ b/extensions/ios.js
@@ -92,11 +92,11 @@ function BuffeeIOS(editor) {
     let row = Math.max(0, Math.floor(y / lineHeight));
     const col = Math.max(0, Math.floor(x / ch));
     // Bounds check row to last meaningful viewport row
-    const linesFromViewStart = Model.end - View.start;
-    const lastMeaningfulViewRow = Math.min(View.n - 1, linesFromViewStart);
+    const linesFromViewFirst = Model.last - View.first;
+    const lastMeaningfulViewRow = Math.min(View.n - 1, linesFromViewFirst);
     row = Math.min(row, lastMeaningfulViewRow);
     // Convert to absolute row
-    const absRow = View.start + row;
+    const absRow = View.first + row;
     // Bounds check col to line length
     const lineLength = Model._[absRow].length;
     Span.cursor({ y: absRow, x: Math.min(col, lineLength) });
diff --git a/extensions/statusline.js b/extensions/statusline.js
index fd5a66ad..b16f9134 100644
--- a/extensions/statusline.js
+++ b/extensions/statusline.js
@@ -48,7 +48,7 @@ function BuffeeStatusLine(editor, { showSelection = false } = {}) {
     const [head, tail] = editor.Span.bounds();  // head first, unordered
     const [start, end] = editor.Span.bounds(1); // ordered by position
     const hasSelection = editor.Span.dir !== 0;
-    const lineCount = Model.end + 1;
+    const lineCount = Model.last + 1;
 
     if (showSelection && hasSelection) {
       // Show selection range: "1:5 - 3:10"
diff --git a/extensions/ultrahighcapacity.js b/extensions/ultrahighcapacity.js
index 1eada6be..743e1e69 100644
--- a/extensions/ultrahighcapacity.js
+++ b/extensions/ultrahighcapacity.js
@@ -20,7 +20,7 @@ function BuffeeUltraHighCapacity(editor) {
   const $e = $.querySelector('.buffee-pane');
 
   // Store original methods/getters
-  const originalLastIndexGetter = Object.getOwnPropertyDescriptor(Model, 'end').get;
+  const originalLastIndexGetter = Object.getOwnPropertyDescriptor(Model, 'last').get;
 
   // Chunk state
   let enabled = false;
@@ -113,7 +113,7 @@ function BuffeeUltraHighCapacity(editor) {
    * @private
    */
   function loadChunksForView() {
-    const startChunkIndex = Math.floor(View.start / chunkSize);
+    const startChunkIndex = Math.floor(View.first / chunkSize);
 
     // Check if we need to load new chunks
     if (currentChunkIndex !== startChunkIndex) {
@@ -303,8 +303,8 @@ function BuffeeUltraHighCapacity(editor) {
       // Set navigation-only mode (can move cursor, no editing)
       editor.Mode.i = 0;
 
-      // Override Model.end
-      Object.defineProperty(Model, 'end', {
+      // Override Model.last
+      Object.defineProperty(Model, 'last', {
         get: () => totalLines - 1,
         configurable: true
       });
@@ -318,8 +318,8 @@ function BuffeeUltraHighCapacity(editor) {
     deactivate() {
       enabled = false;
 
-      // Restore original end getter
-      Object.defineProperty(Model, 'end', {
+      // Restore original last getter
+      Object.defineProperty(Model, 'last', {
         get: originalLastIndexGetter,
         configurable: true
       });

From 842d7f5feeee4379651d93e6651feba4f2c92b8b Mon Sep 17 00:00:00 2001
From: varrockbank 
Date: Tue, 13 Jan 2026 07:25:56 +0100
Subject: [PATCH 05/50] refactor(api): remove Model.s api, delegate to user to
 render after Model._ update

---
 API.md                            | 18 +++----
 CLAUDE.md                         |  9 ++--
 buffee.js                         | 14 ++----
 dev/backlog.txt                   |  4 +-
 dev/changelog.txt                 |  9 ++--
 dist/buffee.min.js                |  2 +-
 docs/api.md                       | 62 +++++++----------------
 docs/extensions.md                | 20 ++++++++
 docs/onboarding.md                |  3 +-
 extensions/fileloader.js          |  3 +-
 extensions/highlights.js          |  3 ++
 extensions/sanitize.js            | 12 -----
 extensions/statusline.js          | 26 ++--------
 extensions/syntax.js              | 22 +--------
 extensions/treesitter.js          |  2 +-
 extensions/tui.js                 |  4 +-
 index.html                        |  6 +--
 samples/_template.html            |  3 +-
 samples/sample-basic.html         | 19 ++++---
 samples/sample-conway.html        |  3 +-
 samples/sample-elementals.html    |  6 +--
 samples/sample-gutter-status.html | 20 +++++---
 samples/sample-history.html       |  8 +--
 samples/sample-ios.html           | 19 ++++---
 samples/sample-loader.html        |  5 +-
 samples/sample-matrix.html        |  3 +-
 samples/sample-movie.html         |  9 ++--
 samples/sample-readonly.html      |  3 +-
 samples/sample-sizing.html        | 40 +++++++++------
 samples/sample-syntax.html        | 11 +++--
 samples/sample-tui.html           |  8 +--
 samples/sample-undotree.html      |  3 +-
 test/lib/test-extensions.js       | 82 +++++++++++++++++--------------
 test/lib/test-ui.js               | 12 +++--
 test/specs/spec-features.dsl      | 10 ++--
 test/specs/spec-navigation.dsl    | 12 ++---
 test/specs/spec-selection.dsl     |  8 +--
 web/extensions-profile.html       |  7 +--
 web/extensions.html               |  8 ++-
 web/themes.html                   |  4 +-
 web/wrappers.html                 |  9 ++--
 wrappers/react.jsx                |  6 ++-
 wrappers/svelte.svelte            |  6 ++-
 wrappers/vue.js                   |  6 ++-
 44 files changed, 279 insertions(+), 270 deletions(-)

diff --git a/API.md b/API.md
index 64fbf134..003187a3 100644
--- a/API.md
+++ b/API.md
@@ -123,12 +123,12 @@ Read-only. Returns the line height in pixels, derived from CSS variable `--buffe
 ## Model (`editor.Model`)
 
 ```javascript
-// Set content
-editor.Model.s = "Hello\nWorld";
+// Set content (array of lines)
+editor.Model._ = ["Hello", "World"];
 
 // Access lines
-editor.Model._;        // ["Hello", "World"]
-editor.Model.end;    // 1
+editor.Model._;       // ["Hello", "World"]
+editor.Model.last;    // 1
 ```
 
 ---
@@ -137,8 +137,8 @@ editor.Model.end;    // 1
 
 ```javascript
 // Read
-editor.View.start;  // First visible line (0-based)
-editor.View.end;    // Last visible line
+editor.View.first;  // First visible line (0-based)
+editor.View.last;   // Last visible line
 editor.View.n;   // Number of visible lines
 editor.View._;  // Array of visible line strings
 
@@ -322,13 +322,13 @@ editor.editMode = 'read';
 
 **Simple view-only mode:**
 ```javascript
-editor.Model.s = "Your content here";
+editor.Model._ = ["Your content here"];
 editor.editMode = 'navigate';
 ```
 
 **TUI mode** (for interactive elements):
 ```javascript
-editor.Model.s = "Your content here";
+editor.Model._ = ["Your content here"];
 editor.TUI.enabled = true;  // Sets editMode to 'read' automatically
 ```
 
@@ -358,7 +358,7 @@ BuffeeTreeSitter(editor, { parser: jsParser, query: jsQuery });
 editor.TreeSitter.enabled = true;
 
 // After modifying content, mark as dirty to trigger re-parse
-editor.Model.s = "function hello() { return 'world'; }";
+editor.Model._ = ["function hello() { return 'world'; }"];
 editor.TreeSitter.markDirty();
 
 // Force immediate re-parse
diff --git a/CLAUDE.md b/CLAUDE.md
index 76b478dc..d60598c8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,11 +10,10 @@
 ```javascript
 BuffeeStatusLine(new Buffee(el, { rows: 20, cols: 80, s: 4 }));
 
-editor.Model.s = "content";   // Set content
-editor.Model._;               // ["line1", "line2"]
-editor.View.set(5);           // Scroll to line index 5
-editor.Span.ins("text");      // Insert at cursor
-editor.Span.cursor({y:0,x:0});// Move cursor
+editor.Model._ = ["line1", "line2"];  // Set content (array of lines)
+editor.View.set(5);                   // Scroll to line index 5
+editor.Span.ins(["text"]);            // Insert at cursor
+editor.Span.cursor({y:0,x:0});        // Move cursor
 ```
 
 ## Required HTML Structure
diff --git a/buffee.js b/buffee.js
index 18d245bc..db32aa71 100644
--- a/buffee.js
+++ b/buffee.js
@@ -14,10 +14,11 @@
  * @param {number} [config.s=4] - Spaces per tab/indentation
  * @example
  * const editor = new Buffee(document.getElementById('editor'), { rows: 25 });
- * editor.Model.s = 'Hello, World!';
+ * editor.Model._ = ['Hello, World!'];
+ * editor.render();
  */
 function Buffee($, { rows, cols, s = 4 } = {}) {
-  this.v = '15.3.0-alpha.1';
+  this.v = '15.4.0-alpha.1';
   this.$ = $;
   const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u;
   // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative).
@@ -272,15 +273,6 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
      */
     get last() { return this._.length - 1 },
 
-    /**
-     * Sets the document content from a string. Splits on newlines.
-     * @param {string} text - The full document text
-     */
-    set s(text) {
-      this._ = text.split('\n');
-      render();
-    },
-
     /**
      * Primitive insert operation. Inserts lines at position.
      * @param {number} row - Row index (absolute, not viewport-relative)
diff --git a/dev/backlog.txt b/dev/backlog.txt
index 394592ee..f6caf09c 100644
--- a/dev/backlog.txt
+++ b/dev/backlog.txt
@@ -1,6 +1,8 @@
 Website
 16. everywhere in web page should use buffee 
 
+Model.end should return coordinate not just last line 
 
+Rename Mode to State 
 
-Model.end should return coordinate not just last line 
+TODO: consolidate 182-220 on extensions.md
\ No newline at end of file
diff --git a/dev/changelog.txt b/dev/changelog.txt
index 3d147d60..3a232300 100644
--- a/dev/changelog.txt
+++ b/dev/changelog.txt
@@ -1,9 +1,12 @@
 * Project Devlog
 
+** 15.4.0-alpha.1 [2026-01-13]  gz: 2589 / 2.53 KB (-17), br: 2393 / 2.34 KB (-12), min: 5813 / 5.68 KB (-35)
+- api: remove Model.s = "foobar". Delegate to user to render on Model._ = ["foobar"]
+
 ** 15.3.0-alpha.1 [2026-01-13]  gz: 2606 / 2.54 KB (+6), br: 2405 / 2.35 KB (+3), min: 5848 / 5.71 KB (14)
-- refactor: rename Model.end to Model.last
-- refactor: rename View.start to View.first
-- refactor: rename View.end to View.last
+- api: rename Model.end to Model.last
+- api: rename View.start to View.first
+- api: rename View.end to View.last
 
 ** 15.2.0-alpha.1 [2026-01-12]  gz: 2600 / 2.54 KB (-1), br: 2402 / 2.35 KB (+7), min: 5834 / 5.70 KB (15)
 - breaking: Span.ins now takes array of lines instead of string
diff --git a/dist/buffee.min.js b/dist/buffee.min.js
index 120f977a..2f5634d3 100644
--- a/dist/buffee.min.js
+++ b/dist/buffee.min.js
@@ -1 +1 @@
-function Buffee(t,{rows:e,cols:s,s:i=4}={}){this.v="15.3.0-alpha.1",this.$=t;const n=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,f,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[d,p,u,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=p.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.first+e]??null],[_,(t,e)=>t.textContent=L.first+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.last?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(i?s:0)){let t=c.x;const r=i?()=>tt>0,h=i?()=>t++:()=>t--;if(n.test(e[t])){for(;r()&&n.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(i?c.y0)&&(c.x=i?0:E._[--c.y].length,i&&++c.y>L.last?L.set(c.y-L.n+1):!i&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.last?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let i=e.y;i<=s.y;i++){const n=E._[i];if(t>0)E._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);E._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:i,i:1,f:0,ch:a,cw:u.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get last(){return this._.length-1},set s(t){this._=t.split("\n"),$()},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},L=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,E.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,E.last)),C(s)},get _(){return E._.slice(this.first,this.last+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(f,(L.first+L.N).toString().length)+x;_.style.width=t+"ch",s&&(d.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),i=Math.min(L.first+L.n,s.y+1);for(let e=Math.max(L.first,t.y);e=0&&ns?l-s:0))/D.cw)*D.cw}}u.style.left=e+"ch",D.sub.forEach(e=>e(p,L,t))};s&&!_&&(d.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=p.getBoundingClientRect(),R(Math.floor(d.clientHeight/a)-L.n)}).observe(d),p.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),p.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};p.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||i?M.dent(i?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&M.dir?M.cursor():i&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!i&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.last));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.last?L.set(e-L.n+1):$()}else i&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})}
\ No newline at end of file
+function Buffee(t,{rows:e,cols:s,s:i=4}={}){this.v="15.4.0-alpha.1",this.$=t;const n=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,f,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[d,u,p,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=u.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.first+e]??null],[_,(t,e)=>t.textContent=L.first+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.last?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(i?s:0)){let t=c.x;const r=i?()=>tt>0,h=i?()=>t++:()=>t--;if(n.test(e[t])){for(;r()&&n.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(i?c.y0)&&(c.x=i?0:E._[--c.y].length,i&&++c.y>L.last?L.set(c.y-L.n+1):!i&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.last?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let i=e.y;i<=s.y;i++){const n=E._[i];if(t>0)E._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);E._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:i,i:1,f:0,ch:a,cw:p.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get last(){return this._.length-1},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},L=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,E.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,E.last)),C(s)},get _(){return E._.slice(this.first,this.last+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(f,(L.first+L.N).toString().length)+x;_.style.width=t+"ch",s&&(d.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),i=Math.min(L.first+L.n,s.y+1);for(let e=Math.max(L.first,t.y);e=0&&ns?l-s:0))/D.cw)*D.cw}}p.style.left=e+"ch",D.sub.forEach(e=>e(u,L,t))};s&&!_&&(d.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=u.getBoundingClientRect(),R(Math.floor(d.clientHeight/a)-L.n)}).observe(d),u.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),u.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};u.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||i?M.dent(i?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&M.dir?M.cursor():i&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!i&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.last));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.last?L.set(e-L.n+1):$()}else i&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})}
\ No newline at end of file
diff --git a/docs/api.md b/docs/api.md
index f1f26f09..578f177d 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -12,20 +12,6 @@ const editor = new Buffee(element, { rows, cols, s })
 | `cols` | number | auto | Fixed text columns |
 | `s` | number | 4 | Tab width (0 = hard tabs) |
 
-## Text Sanitization
-
-Buffee does **not** sanitize text. Content set via `Model.s` or `Span.ins()` is inserted as-is. This means:
-
-- **Tabs** (`\t`) render with browser-default variable width, breaking grid alignment
-- **Zero-width characters** (ZWSP, ZWNJ, ZWJ, BOM) cause invisible cursor drift
-- **Multi-width Unicode spaces** (em space, en space, etc.) misalign subsequent characters
-
-**Solutions:**
-- Use `BuffeeSanitize` extension for automatic sanitization
-- Pre-sanitize text before passing to Buffee
-
-Note: The keyboard controller converts Tab key presses to spaces—only programmatic content is affected.
-
 ## Top-level properties
 
 ```javascript
@@ -40,34 +26,42 @@ editor
   .Mode              // see Mode  namespace below
 ```
 
-## Model (`editor.Model`)\
+## Model (`editor.Model`)
 
 ```javascript
 Model
-  ._    // Array of text lines, without '\n
-  .s    // Set content (string with \n)
-  .last // Index of last line of Model
+  ._    // text buffer. Assumes user sanitized '\n', '\t', zero-width, multi-width chars 
   .ins  // primitive insert
   .del  // primitive del
+
+  // Convenience utilities
+  .last // index of last line of Model
 ```
 
-## View (`editor.View`)
+When updating buffer, recall render if necessary. Suppose you append to ._ and the new lines 
+are out of view, then a render would not be necessary. While we could have added a setter 
+for the model that would know to call render, this would meant that the Model has to be concerned
+with the view. The philosophy is that Model should be agnostic to existence of rendering.
 
-The paradigm is to define first and size, but last is derived. An alternative implementation
-was first and last, but size is derived. The latter's API appears symmetrical but it was not
-as intuitive and the implementation uglier.
+## View (`editor.View`)
 
 ```javascript
 View
   .first         // Model index of first line of viewport 
-  .last          // Model index of last line viewport 
   .n             // Viewport size - number of lines (settable)
-  .N             // Number of DOM containers (n + 1 if auto-fit)
   .set(first)    // Scroll to line, keep current size
   .set(first, n) // Scroll to line with new size
   ._             // Visible lines array (derived: Model._.slice(first, last + 1))
+
+  // Convenience utilities
+  .last          // Model index of last line viewport 
+  .N             // Number of DOM containers (n + 1 if auto-fit)
 ```
 
+The paradigm is to define first and size, but last is derived. An alternative implementation
+was first and last, but size is derived. The latter's API appears symmetrical but it was not
+as intuitive and the implementation uglier.
+
 ## Span (`editor.Span`)
 
 A continuous text span from a starting and end coordinate. 
@@ -99,23 +93,3 @@ Mode
   .sub          // subscriptions for render callback
   .ext          // Array of registered extension names (in order)
 ```
-
-## Extension API
-
-For building extensions:
-
-```javascript
-const { Model, View, Span, Mode, render, $ } = editor;
-
-// Register render hook
-Mode.renderHooks.push(($container, viewport, rebuilt) => {
-  // Called after each render
-});
-
-// Wrap primitives
-const originalIns = Model.ins.bind(Model);
-Model.ins = function(row, col, lines) {
-  // Custom logic
-  return originalIns(row, col, lines);
-};
-```
diff --git a/docs/extensions.md b/docs/extensions.md
index 483489ed..b92a74b6 100644
--- a/docs/extensions.md
+++ b/docs/extensions.md
@@ -179,6 +179,26 @@ Enables touch-to-position cursor and virtual keyboard handling.
 
 ---
 
+## Extension API
+
+For building extensions:
+
+```javascript
+const { Model, View, Span, Mode, render, $ } = editor;
+
+// Register render hook
+Mode.renderHooks.push(($container, viewport, rebuilt) => {
+  // Called after each render
+});
+
+// Wrap primitives
+const originalIns = Model.ins.bind(Model);
+Model.ins = function(row, col, lines) {
+  // Custom logic
+  return originalIns(row, col, lines);
+};
+```
+
 ## Creating Extensions
 
 ```javascript
diff --git a/docs/onboarding.md b/docs/onboarding.md
index a6eb31e9..ca2d6137 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -52,7 +52,8 @@ BuffeeStatusLine(editor);
 ## Set Content
 
 ```javascript
-editor.Model.s = "Hello, World!";
+editor.Model._ = ["Hello, World!"];  // Array of lines
+editor.render();  // Trigger re-render after setting content
 ```
 
 ## Sizing
diff --git a/extensions/fileloader.js b/extensions/fileloader.js
index aed229dc..500b50f4 100644
--- a/extensions/fileloader.js
+++ b/extensions/fileloader.js
@@ -76,7 +76,8 @@ function BuffeeFileLoader(editor) {
     async naiveLoad(file) {
       const t0 = performance.now();
       const text = await file.text();
-      Model.s = text;
+      Model._ = text.split('\n');
+      render();
       const t1 = performance.now();
       return {
         lines: Model._.length,
diff --git a/extensions/highlights.js b/extensions/highlights.js
index 18b97444..a10ebd3f 100644
--- a/extensions/highlights.js
+++ b/extensions/highlights.js
@@ -11,6 +11,9 @@
  * @returns {Buffee} The extended editor instance
  */
 function BuffeeHighlights(editor) {
+  // Idempotent: return if already applied
+  if (editor.Highlights) return editor;
+
   const { $, Mode } = editor;
   const lineHeight = Mode.ch;
 
diff --git a/extensions/sanitize.js b/extensions/sanitize.js
index 1a748c54..d285e3cf 100644
--- a/extensions/sanitize.js
+++ b/extensions/sanitize.js
@@ -67,18 +67,6 @@ function BuffeeSanitize(editor) {
     origIns(row, col, sanitizeLines(lines));
   };
 
-  // Wrap Model.s setter to sanitize content
-  const origSetter = Object.getOwnPropertyDescriptor(Model, 's') ||
-                     Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Model), 's');
-
-  Object.defineProperty(Model, 's', {
-    set(text) {
-      Model._ = sanitizeText(text).split('\n');
-      editor.render();
-    },
-    configurable: true
-  });
-
   // API
   const Sanitize = {
     /** Sanitize a single line */
diff --git a/extensions/statusline.js b/extensions/statusline.js
index b16f9134..431a0ca4 100644
--- a/extensions/statusline.js
+++ b/extensions/statusline.js
@@ -23,26 +23,7 @@ function BuffeeStatusLine(editor, { showSelection = false } = {}) {
   const $spaces = $.querySelector('.buffee-spaces');
 
   let lastRow = -1, lastCol = -1, lastEndRow = -1, lastEndCol = -1, lastHasSelection = false;
-  let lastLineCount = -1, lastSpaces = -1, lastOriginalLineCount = -1;
-  let byteCount = 0, originalLineCount = 0;
-
-  // Capture initial state if text was already set before this extension
-  if (Model._.length > 1 || Model._[0] !== '') {
-    const text = Model._.join('\n');
-    byteCount = new TextEncoder().encode(text).length;
-    originalLineCount = Model._.length;
-  }
-
-  // Wrap Model.s setter to calculate byteCount and originalLineCount
-  const originalTextDescriptor = Object.getOwnPropertyDescriptor(Model, 's');
-  Object.defineProperty(Model, 's', {
-    set(text) {
-      byteCount = new TextEncoder().encode(text).length;
-      originalLineCount = text.split('\n').length;
-      originalTextDescriptor.set.call(this, text);
-    },
-    configurable: true
-  });
+  let lastLineCount = -1, lastSpaces = -1;
 
   function updateStatusLine() {
     const [head, tail] = editor.Span.bounds();  // head first, unordered
@@ -88,10 +69,9 @@ function BuffeeStatusLine(editor, { showSelection = false } = {}) {
       lastEndCol = -1;
     }
 
-    if ($lineCounter && (lineCount !== lastLineCount || originalLineCount !== lastOriginalLineCount)) {
-      $lineCounter.textContent = `${lineCount.toLocaleString()}L, originally: ${originalLineCount}L ${byteCount} bytes`;
+    if ($lineCounter && lineCount !== lastLineCount) {
+      $lineCounter.textContent = `${lineCount.toLocaleString()} lines`;
       lastLineCount = lineCount;
-      lastOriginalLineCount = originalLineCount;
     }
     if ($spaces && Mode.s !== lastSpaces) {
       $spaces.textContent = `Spaces: ${Mode.s}`;
diff --git a/extensions/syntax.js b/extensions/syntax.js
index 91530f0d..dd2b7856 100644
--- a/extensions/syntax.js
+++ b/extensions/syntax.js
@@ -47,24 +47,6 @@ function BuffeeSyntax(editor) {
     }
   };
 
-  // Hook Model.s setter for bulk content changes
-  const textDescriptor = Object.getOwnPropertyDescriptor(Model, 's');
-  if (textDescriptor && textDescriptor.set) {
-    const originalTextSetter = textDescriptor.set;
-    Object.defineProperty(Model, 's', {
-      set: function(text) {
-        originalTextSetter.call(this, text);
-        if (enabled) {
-          // Full document change - reset cache completely
-          stateCache.length = 1;
-          stateCache[0] = 0;
-        }
-      },
-      get: textDescriptor.get,
-      configurable: true
-    });
-  }
-
   // Built-in token types with default colors
   const defaultColors = {
     keyword: '#C678DD',
@@ -258,13 +240,13 @@ function BuffeeSyntax(editor) {
     if (!enabled || !language) return;
 
     // Ensure we have state cache up to viewport end
-    ensureStateCache(viewport.start + viewport.n);
+    ensureStateCache(viewport.first + viewport.n);
 
     // Use $textLayer which contains the pre elements (not $container which is $e)
     const lineContainer = $textLayer || $container;
 
     for (let i = 0; i < viewport.n; i++) {
-      const absLine = viewport.start + i;
+      const absLine = viewport.first + i;
       if (absLine >= Model._.length) break;
 
       const lineEl = lineContainer.children[i];
diff --git a/extensions/treesitter.js b/extensions/treesitter.js
index 572374c4..b7c8b6ef 100644
--- a/extensions/treesitter.js
+++ b/extensions/treesitter.js
@@ -110,7 +110,7 @@ function BuffeeTreeSitter(editor, { parser, query }) {
       $line.textContent = viewport.lines[i] || null;
 
       // Apply highlighting
-      minJ = highlightLine($line, viewport.start + i, minJ);
+      minJ = highlightLine($line, viewport.first + i, minJ);
     }
   });
 
diff --git a/extensions/tui.js b/extensions/tui.js
index d2825291..1a21f8a2 100644
--- a/extensions/tui.js
+++ b/extensions/tui.js
@@ -286,7 +286,7 @@ function BuffeeTUI(editor) {
       for (const el of elements) {
         for (let i = 0; i < el.contents.length; i++) {
           const absRow = el.row + i;
-          const viewportRow = absRow - viewport.start;
+          const viewportRow = absRow - viewport.first;
 
           if (viewportRow >= 0 && viewportRow < viewport.n) {
             const $line = $textLayer.children[viewportRow];
@@ -317,7 +317,7 @@ function BuffeeTUI(editor) {
 
     for (let i = 0; i < currentEl.contents.length; i++) {
       const absRow = currentEl.row + i;
-      const viewportRow = absRow - viewport.start;
+      const viewportRow = absRow - viewport.first;
 
       if (viewportRow >= 0 && viewportRow < viewport.n) {
         Highlights.create(viewportRow, currentEl.col, currentEl.width);
diff --git a/index.html b/index.html
index 4f58eba8..c215191a 100644
--- a/index.html
+++ b/index.html
@@ -187,7 +187,7 @@ 

HackerNews Front Page

fetch("assets/instructions.txt") .then(response => response.text()) - .then(source => primary.Model.s = source) + .then(source => { primary.Model._ = source.split('\n'); primary.render(); }) .catch(err => console.error("Error reading instructions.txt:", err)); // Fetch Hacker News as plaintext @@ -218,7 +218,7 @@

HackerNews Front Page

} fetchHackerNewsText() - .then(text => hackernews.Model.s = text) + .then(text => { hackernews.Model._ = text.split('\n'); hackernews.render(); }) .catch(err => console.error("Error fetching Hacker News:", err)); // TUI Mode Demo @@ -350,7 +350,7 @@

HackerNews Front Page

} // Initialize with template - tuiDemo.Model.s = fileTemplate.trim(); + tuiDemo.Model._ = fileTemplate.trim().split('\n'); // Show initial file (also creates the file list elements) loadFile('main.js'); diff --git a/samples/_template.html b/samples/_template.html index 535782aa..5ad50368 100644 --- a/samples/_template.html +++ b/samples/_template.html @@ -60,7 +60,8 @@

[Sample Name]

// BuffeeSyntax(editor); // Set content - editor.Model.s = 'Hello, World!'; + editor.Model._ = ['Hello, World!']; + editor.render(); diff --git a/samples/sample-basic.html b/samples/sample-basic.html index d1af34a4..cb85e60b 100644 --- a/samples/sample-basic.html +++ b/samples/sample-basic.html @@ -47,14 +47,17 @@

Basic Editor

const el = document.getElementById('editor'); const editor = BuffeeStatusLine(new Buffee(el, { rows: 10 })); - editor.Model.s = `// Welcome to buffee -// Start typing... - -function hello() { - console.log("Hello, world!"); -} - -hello();`; + editor.Model._ = [ + '// Welcome to buffee', + '// Start typing...', + '', + 'function hello() {', + ' console.log("Hello, world!");', + '}', + '', + 'hello();' + ]; + editor.render(); el.focus(); diff --git a/samples/sample-conway.html b/samples/sample-conway.html index 32f2e4d6..20b51567 100644 --- a/samples/sample-conway.html +++ b/samples/sample-conway.html @@ -120,7 +120,8 @@

Conway's Game of Life

} lines.push(line); } - editor.Model.s = lines.join('\n'); + editor.Model._ = lines; + editor.render(); } function playConway() { diff --git a/samples/sample-elementals.html b/samples/sample-elementals.html index 7a080dba..f49d78c0 100644 --- a/samples/sample-elementals.html +++ b/samples/sample-elementals.html @@ -115,7 +115,7 @@

Mixed Elements

const buttonsEl = document.getElementById('buttons-demo'); const buttonsEditor = BuffeeStatusLine(new Buffee(buttonsEl, { rows: 5 })); BuffeeElementals(buttonsEditor); - buttonsEditor.Model.s = '\n\n\n\n'; + buttonsEditor.Model._ = ['', '', '', '', '']; buttonsEditor.Elementals.addButton({ row: 1, col: 2, label: 'Save', @@ -151,7 +151,7 @@

Mixed Elements

const inputsEl = document.getElementById('inputs-demo'); const inputsEditor = BuffeeStatusLine(new Buffee(inputsEl, { rows: 5 })); BuffeeElementals(inputsEditor); - inputsEditor.Model.s = '\n\n\n\n'; + inputsEditor.Model._ = ['', '', '', '', '']; inputsEditor.Elementals.addLabel({ row: 1, col: 2, text: 'Name:' }); inputsEditor.Elementals.addInput({ row: 1, col: 9, @@ -178,7 +178,7 @@

Mixed Elements

const mixedEl = document.getElementById('mixed-demo'); const mixedEditor = BuffeeStatusLine(new Buffee(mixedEl, { rows: 8 })); BuffeeElementals(mixedEditor); - mixedEditor.Model.s = '\n\n\n\n\n\n\n'; + mixedEditor.Model._ = ['', '', '', '', '', '', '', '']; // Form layout mixedEditor.Elementals.addLabel({ row: 1, col: 2, text: 'Search:' }); diff --git a/samples/sample-gutter-status.html b/samples/sample-gutter-status.html index b15887b2..a7d4040b 100644 --- a/samples/sample-gutter-status.html +++ b/samples/sample-gutter-status.html @@ -171,37 +171,45 @@

6. Gutter on right

hello();`; + const contentLines = content.split('\n'); + // Default const elDefault = document.getElementById('editor-default'); const edDefault = BuffeeStatusLine(new Buffee(elDefault, { rows: 6 })); - edDefault.Model.s = content; + edDefault.Model._ = contentLines.slice(); + edDefault.render(); // No gutter const elNoGutter = document.getElementById('editor-no-gutter'); const edNoGutter = BuffeeStatusLine(new Buffee(elNoGutter, { rows: 10 })); - edNoGutter.Model.s = content; + edNoGutter.Model._ = contentLines.slice(); + edNoGutter.render(); // No status bar const elNoStatus = document.getElementById('editor-no-status'); const edNoStatus = new Buffee(elNoStatus, { rows: 10 }); - edNoStatus.Model.s = content; + edNoStatus.Model._ = contentLines.slice(); + edNoStatus.render(); // Status line on top const elStatusTop = document.getElementById('editor-status-top'); const edStatusTop = BuffeeStatusLine(new Buffee(elStatusTop, { rows: 10 })); - edStatusTop.Model.s = content; + edStatusTop.Model._ = contentLines.slice(); + edStatusTop.render(); // Selection range display const elSelection = document.getElementById('editor-selection'); const edSelection = BuffeeStatusLine(new Buffee(elSelection, { rows: 6 }), { showSelection: true }); - edSelection.Model.s = content; + edSelection.Model._ = contentLines.slice(); + edSelection.render(); // Gutter on right const elGutterRight = document.getElementById('editor-gutter-right'); const edGutterRight = BuffeeStatusLine(new Buffee(elGutterRight, { rows: 10 })); - edGutterRight.Model.s = content; + edGutterRight.Model._ = contentLines.slice(); + edGutterRight.render(); diff --git a/samples/sample-history.html b/samples/sample-history.html index 8e792a69..aaf62c21 100644 --- a/samples/sample-history.html +++ b/samples/sample-history.html @@ -73,15 +73,15 @@

With History

// Editor WITHOUT history - undo/redo will be no-ops const el1 = document.getElementById('editor-no-history'); const editorNoHistory = BuffeeStatusLine(new Buffee(el1, { rows: 10 })); - editorNoHistory.Model.s = `// No history - Cmd+Z won't work -// Try typing and undoing...`; + editorNoHistory.Model._ = ['// No history - Cmd+Z won\'t work', '// Try typing and undoing...']; + editorNoHistory.render(); // Editor WITH history - full undo/redo support const el2 = document.getElementById('editor-with-history'); const editorWithHistory = BuffeeStatusLine(new Buffee(el2, { rows: 10 })); BuffeeHistory(editorWithHistory); // Enable history extension - editorWithHistory.Model.s = `// With history - Cmd+Z works! -// Try typing and undoing...`; + editorWithHistory.Model._ = ['// With history - Cmd+Z works!', '// Try typing and undoing...']; + editorWithHistory.render(); diff --git a/samples/sample-ios.html b/samples/sample-ios.html index 173abe54..8e4c8b9f 100644 --- a/samples/sample-ios.html +++ b/samples/sample-ios.html @@ -55,14 +55,17 @@

JavaScript

BuffeeIOS(editor); } - editor.Model.s = `// iOS Support Demo -// On iOS: tap to position cursor, use on-screen keyboard - -function greet(name) { - return "Hello, " + name + "!"; -} - -console.log(greet("iOS"));`; + editor.Model._ = [ + '// iOS Support Demo', + '// On iOS: tap to position cursor, use on-screen keyboard', + '', + 'function greet(name) {', + ' return "Hello, " + name + "!";', + '}', + '', + 'console.log(greet("iOS"));' + ]; + editor.render(); el.focus(); diff --git a/samples/sample-loader.html b/samples/sample-loader.html index 99812529..2e515a2f 100644 --- a/samples/sample-loader.html +++ b/samples/sample-loader.html @@ -149,7 +149,7 @@

Logs:

} else { logs.Model._.push(s); } - logs.View.start = Math.max(0, logs.Model._.length - logs.View.n - 1); + logs.View.first = Math.max(0, logs.Model._.length - logs.View.n - 1); logs._render(true); } @@ -157,7 +157,8 @@

Logs:

new Buffee(editorEl, { rows: 10, logger: log }) )))); - editor.Model.s = 'Select a file to load...'; + editor.Model._ = ['Select a file to load...']; + editor.render(); editorEl.focus(); // View controls diff --git a/samples/sample-matrix.html b/samples/sample-matrix.html index 3d9b1640..5c701091 100644 --- a/samples/sample-matrix.html +++ b/samples/sample-matrix.html @@ -88,7 +88,8 @@

Matrix Digital Rain

lines.push(line); } - editor.Model.s = lines.join('\n'); + editor.Model._ = lines; + editor.render(); } // Start Matrix animation diff --git a/samples/sample-movie.html b/samples/sample-movie.html index 3d2e268c..9ab37dcf 100644 --- a/samples/sample-movie.html +++ b/samples/sample-movie.html @@ -54,7 +54,8 @@

ASCII Movie

const editorEl = document.getElementById('editor'); const editor = BuffeeStatusLine(new Buffee(editorEl, { rows: 13 })); - editor.Model.s = 'Loading Star Wars ASCII movie...'; + editor.Model._ = ['Loading Star Wars ASCII movie...']; + editor.render(); let movieData = null; let frames = []; @@ -87,13 +88,15 @@

ASCII Movie

showFrame(750); } catch (err) { console.error("Error loading Star Wars movie:", err); - editor.Model.s = "Error loading movie. Check that sw.txt is in the assets directory."; + editor.Model._ = ["Error loading movie. Check that sw.txt is in the assets directory."]; + editor.render(); } } function showFrame(frameIndex) { if (frames.length > 0) { - editor.Model.s = frames[frameIndex % frames.length]; + editor.Model._ = frames[frameIndex % frames.length].split('\n'); + editor.render(); document.getElementById('frame-counter').textContent = `${frameIndex + 1} / ${frames.length}`; document.getElementById('frame-slider').value = frameIndex; } diff --git a/samples/sample-readonly.html b/samples/sample-readonly.html index 4b80b22f..97fde97c 100644 --- a/samples/sample-readonly.html +++ b/samples/sample-readonly.html @@ -84,7 +84,8 @@

2: Ultra High Capacity Mode

const elTui = document.getElementById('editor-tui'); const edTui = BuffeeStatusLine(new Buffee(elTui, { rows: 10 })); BuffeeTUI(edTui); - edTui.Model.s = content; + edTui.Model._ = content.split('\n'); + edTui.render(); edTui.TUI.enabled = true; // Option 2: Ultra High Capacity mode (for large files) diff --git a/samples/sample-sizing.html b/samples/sample-sizing.html index 157323c6..e09ed1b7 100644 --- a/samples/sample-sizing.html +++ b/samples/sample-sizing.html @@ -196,50 +196,58 @@

8. Auto-rows, fixed-cols (contain: inline-size)

// 1. Auto-rows and auto-cols (default behavior) const elAutoBoth = document.getElementById('editor-auto-both'); const edAutoBoth = BuffeeStatusLine(new Buffee(elAutoBoth)); - edAutoBoth.Model.s = ruler(120) + '\n// Auto-rows and auto-cols\n// Drag corner to resize\n' + - Array.from({length: 20}, (_, i) => `Line ${i + 4}`).join('\n'); + edAutoBoth.Model._ = [ruler(120), '// Auto-rows and auto-cols', '// Drag corner to resize'].concat( + Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); + edAutoBoth.render(); // 2. Fixed-rows, auto-cols const elFixedRowsAutoCols = document.getElementById('editor-fixed-rows-auto-cols'); const edFixedRowsAutoCols = BuffeeStatusLine(new Buffee(elFixedRowsAutoCols, { rows: 8 })); - edFixedRowsAutoCols.Model.s = ruler(120) + '\n// Fixed 8 rows, auto-cols\nLine 3\n' + - Array.from({length: 20}, (_, i) => `Line ${i + 4}`).join('\n'); + edFixedRowsAutoCols.Model._ = [ruler(120), '// Fixed 8 rows, auto-cols', 'Line 3'].concat( + Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); + edFixedRowsAutoCols.render(); // 3. rows smaller than container const elFixedRowsLarge = document.getElementById('editor-fixed-rows-large-container'); const edFixedRowsLarge = BuffeeStatusLine(new Buffee(elFixedRowsLarge, { rows: 5 })); - edFixedRowsLarge.Model.s = ruler(80) + '\n// rows: 5 overrides container height\n' + - Array.from({length: 10}, (_, i) => `Line ${i + 3}`).join('\n'); + edFixedRowsLarge.Model._ = [ruler(80), '// rows: 5 overrides container height'].concat( + Array.from({length: 10}, (_, i) => `Line ${i + 3}`)); + edFixedRowsLarge.render(); // 4. rows larger than container const elFixedRowsOverflow = document.getElementById('editor-fixed-rows-overflow'); const edFixedRowsOverflow = BuffeeStatusLine(new Buffee(elFixedRowsOverflow, { rows: 20 })); - edFixedRowsOverflow.Model.s = ruler(80) + '\n// rows: 20 exceeds container height\n' + - Array.from({length: 25}, (_, i) => `Line ${i + 3}`).join('\n'); + edFixedRowsOverflow.Model._ = [ruler(80), '// rows: 20 exceeds container height'].concat( + Array.from({length: 25}, (_, i) => `Line ${i + 3}`)); + edFixedRowsOverflow.render(); // 5. rows larger than container (scrollable) const elFixedRowsScroll = document.getElementById('editor-fixed-rows-scroll'); const edFixedRowsScroll = BuffeeStatusLine(new Buffee(elFixedRowsScroll, { rows: 20 })); - edFixedRowsScroll.Model.s = ruler(80) + '\n// rows: 20 exceeds container height\n// Scroll to see more\n' + - Array.from({length: 25}, (_, i) => `Line ${i + 4}`).join('\n'); + edFixedRowsScroll.Model._ = [ruler(80), '// rows: 20 exceeds container height', '// Scroll to see more'].concat( + Array.from({length: 25}, (_, i) => `Line ${i + 4}`)); + edFixedRowsScroll.render(); // 6. Auto-rows, fixed-cols (status bar stretches) const elFixedCols = document.getElementById('editor-fixed-cols'); const edFixedCols = BuffeeStatusLine(new Buffee(elFixedCols, { cols: 40 })); - edFixedCols.Model.s = ruler(40) + '\n// Fixed 55 cols\n// Status bar stretches\n' + - Array.from({length: 20}, (_, i) => `Line ${i + 4}`).join('\n'); + edFixedCols.Model._ = [ruler(40), '// Fixed 55 cols', '// Status bar stretches'].concat( + Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); + edFixedCols.render(); // 7. Auto-rows, fixed-cols (fit-content) const elFitContent = document.getElementById('editor-fit-content'); const edFitContent = BuffeeStatusLine(new Buffee(elFitContent, { cols: 40 })); - edFitContent.Model.s = ruler(55) + '\n// Fixed 55 cols\n// width: fit-content\n' + - Array.from({length: 20}, (_, i) => `Line ${i + 4}`).join('\n'); + edFitContent.Model._ = [ruler(55), '// Fixed 55 cols', '// width: fit-content'].concat( + Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); + edFitContent.render(); // 8. Auto-rows, fixed-cols (contain: inline-size) const elContain = document.getElementById('editor-contain'); const edContain = BuffeeStatusLine(new Buffee(elContain, { cols: 20 })); - edContain.Model.s = ruler(20) + '\n// Fixed 20 cols\n// contain: inline-size\n' + - Array.from({length: 20}, (_, i) => `Line ${i + 4}`).join('\n'); + edContain.Model._ = [ruler(20), '// Fixed 20 cols', '// contain: inline-size'].concat( + Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); + edContain.render(); diff --git a/samples/sample-syntax.html b/samples/sample-syntax.html index 92b59d4b..7b945df7 100644 --- a/samples/sample-syntax.html +++ b/samples/sample-syntax.html @@ -329,13 +329,16 @@

Hello World

}; // Load initial sample - editor.Model.s = samples.javascript; + editor.Model._ = samples.javascript.split('\n'); + editor.render(); // Language switcher langSelect.addEventListener('change', () => { const lang = langSelect.value; editor.Syntax.setLanguage(lang); - editor.Model.s = samples[lang]; + editor.Syntax.clearCache(); + editor.Model._ = samples[lang].split('\n'); + editor.render(); updateStateDisplay(); }); @@ -430,8 +433,8 @@

Hello World

const viewport = editor.View; const lines = []; - for (let i = 0; i < viewport.n && viewport.start + i < editor.Model._.length; i++) { - const absLine = viewport.start + i; + for (let i = 0; i < viewport.n && viewport.first + i < editor.Model._.length; i++) { + const absLine = viewport.first + i; const state = cache[absLine] !== undefined ? cache[absLine] : '?'; lines.push(`L${absLine + 1}: state=${state}`); } diff --git a/samples/sample-tui.html b/samples/sample-tui.html index 1a60b120..3e4b75f4 100644 --- a/samples/sample-tui.html +++ b/samples/sample-tui.html @@ -106,7 +106,7 @@

ScrollBox (Arrow keys to scroll)

const plainEl = document.getElementById('buttons-plain'); const plainEditor = BuffeeStatusLine(new Buffee(plainEl, { rows: 3 })); BuffeeTUI(plainEditor); - plainEditor.Model.s = '\n Select action:'; + plainEditor.Model._ = ['', ' Select action:']; plainEditor.TUI.addButton({ row: 1, col: 20, label: '[Save]', onActivate: () => { alert('Save clicked'); } }); plainEditor.TUI.addButton({ row: 1, col: 28, label: '[Load]', onActivate: () => { alert('Load clicked'); } }); plainEditor.TUI.addButton({ row: 1, col: 36, label: '[Exit]', onActivate: () => { alert('Exit clicked'); } }); @@ -120,7 +120,7 @@

ScrollBox (Arrow keys to scroll)

const borderEl = document.getElementById('buttons-border'); const borderEditor = BuffeeStatusLine(new Buffee(borderEl, { rows: 5 })); BuffeeTUI(borderEditor); - borderEditor.Model.s = '\n\n\n\n'; + borderEditor.Model._ = ['', '', '', '', '']; borderEditor.TUI.addButton({ row: 1, col: 2, label: ' OK ', border: true, onActivate: () => { alert('OK clicked'); } }); borderEditor.TUI.addButton({ row: 1, col: 12, label: ' Cancel ', border: true, onActivate: () => { alert('Cancel clicked'); } }); borderEditor.TUI.addButton({ row: 1, col: 26, label: ' Apply ', border: true, onActivate: () => { alert('Apply clicked'); } }); @@ -134,7 +134,7 @@

ScrollBox (Arrow keys to scroll)

const promptEl = document.getElementById('prompt-demo'); const promptEditor = BuffeeStatusLine(new Buffee(promptEl, { rows: 8 })); BuffeeTUI(promptEditor); - promptEditor.Model.s = '\n\n\n\n\n\n\n'; + promptEditor.Model._ = ['', '', '', '', '', '', '', '']; promptEditor.TUI.addPrompt({ row: 1, col: 2, width: 30, title: 'Search', onActivate: (el) => { alert('Search: ' + el.input); } }); promptEditor.TUI.addPrompt({ row: 5, col: 2, width: 40, title: 'Command', onActivate: (el) => { alert('Command: ' + el.input); } }); promptEditor.TUI.enabled = true; @@ -147,7 +147,7 @@

ScrollBox (Arrow keys to scroll)

const scrollEl = document.getElementById('scrollbox-demo'); const scrollEditor = BuffeeStatusLine(new Buffee(scrollEl, { rows: 10 })); BuffeeTUI(scrollEditor); - scrollEditor.Model.s = '\n\n\n\n\n\n\n\n\n'; + scrollEditor.Model._ = ['', '', '', '', '', '', '', '', '', '']; const logLines = [ '[INFO] Server started on port 3000', '[INFO] Database connected', diff --git a/samples/sample-undotree.html b/samples/sample-undotree.html index e94f8499..90a7c654 100644 --- a/samples/sample-undotree.html +++ b/samples/sample-undotree.html @@ -266,7 +266,8 @@

History Tree

btnClear.onclick = () => { editor.UndoTree.clear(); - editor.Model.s = ''; + editor.Model._ = ['']; + editor.render(); updateTree(); }; diff --git a/test/lib/test-extensions.js b/test/lib/test-extensions.js index 9226fa97..cea8583a 100644 --- a/test/lib/test-extensions.js +++ b/test/lib/test-extensions.js @@ -131,7 +131,8 @@ function defineExtensionTests() { BuffeeSyntax(editor); editor.Syntax.setLanguage('javascript'); editor.Syntax.enabled = true; - editor.Model.s = 'const x = 42;'; + editor.Model._ = ['const x = 42;']; + editor.render(); const { tokens } = editor.Syntax.tokenizeLine('const x = 42;', 0); assertTrue(tokens.length > 0, 'Should have tokens'); @@ -160,7 +161,8 @@ function defineExtensionTests() { try { BuffeeSyntax(editor); editor.Syntax.setLanguage('javascript'); - editor.Model.s = '/* start\nmiddle\nend */'; + editor.Model._ = ['/* start', 'middle', 'end */']; + editor.render(); // First line starts comment const result1 = editor.Syntax.tokenizeLine('/* start', 0); @@ -184,14 +186,15 @@ function defineExtensionTests() { BuffeeSyntax(editor); editor.Syntax.setLanguage('javascript'); editor.Syntax.enabled = true; - editor.Model.s = 'line1\nline2\nline3'; + editor.Model._ = ['line1', 'line2', 'line3']; + editor.render(); // Force state cache population editor.Syntax.ensureStateCache(2); assertTrue(editor.Syntax.stateCache.length >= 3, 'State cache should be populated'); - // Simulate edit by setting text - editor.Model.s = 'changed'; + // clearCache should reset the cache + editor.Syntax.clearCache(); assertEqual(editor.Syntax.stateCache.length, 1, 'State cache should be reset'); } finally { cleanup(); @@ -230,7 +233,7 @@ function defineExtensionTests() { editor.Syntax.setLanguage('javascript'); editor.Syntax.enabled = true; // Create 10 lines to fill viewport - editor.Model.s = 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nconst e = 5;\nconst f = 6;\nconst g = 7;\nconst h = 8;\nconst i = 9;\nconst j = 10;'; + editor.Model._ = ['const a = 1;', 'const b = 2;', 'const c = 3;', 'const d = 4;', 'const e = 5;', 'const f = 6;', 'const g = 7;', 'const h = 8;', 'const i = 9;', 'const j = 10;']; editor.render(); // Check that highlighting was applied to lines in viewport @@ -243,8 +246,8 @@ function defineExtensionTests() { } }); - // Regression: Model.s setter hook must invalidate cache - extRunner.it('resets state cache when Model.s is set', () => { + // Regression: clearCache must invalidate state cache + extRunner.it('resets state cache when clearCache is called', () => { const { editor, cleanup } = createTestEditor(); try { BuffeeSyntax(editor); @@ -252,13 +255,14 @@ function defineExtensionTests() { editor.Syntax.enabled = true; // Set initial multiline content - editor.Model.s = '/* comment\nstill comment\nend */'; + editor.Model._ = ['/* comment', 'still comment', 'end */']; + editor.render(); editor.Syntax.ensureStateCache(3); assertTrue(editor.Syntax.stateCache.length >= 3, 'Cache should be populated'); - // Setting Model.s should reset cache - editor.Model.s = 'new content'; - assertEqual(editor.Syntax.stateCache.length, 1, 'Cache should be reset to 1 after Model.s set'); + // clearCache should reset cache + editor.Syntax.clearCache(); + assertEqual(editor.Syntax.stateCache.length, 1, 'Cache should be reset to 1 after clearCache'); } finally { cleanup(); } @@ -272,7 +276,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n\n\n'; + editor.Model._ = ['', '', '', '', '', '']; editor.Elementals.addButton({ row: 2, col: 5, label: 'Test' }); editor.Elementals.enabled = true; editor.render(); @@ -290,7 +294,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n\n\n'; + editor.Model._ = ['', '', '', '', '', '']; // Add in non-sorted order: C at row 3, A at row 1 col 2, B at row 1 col 5 editor.Elementals.addButton({ row: 3, col: 1, label: 'C' }); editor.Elementals.addButton({ row: 1, col: 2, label: 'A' }); @@ -315,7 +319,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n'; + editor.Model._ = ['', '', '', '']; const id = editor.Elementals.addButton({ row: 1, col: 5, label: 'Test' }); @@ -331,7 +335,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n'; + editor.Model._ = ['', '', '', '']; editor.Elementals.addLabel({ row: 1, col: 5, text: 'Label' }); assertEqual(editor.Elementals.elements.length, 1, 'Should have 1 element'); assertEqual(editor.Elementals.elements[0].type, 'label', 'Should be label type'); @@ -344,7 +348,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n'; + editor.Model._ = ['', '', '', '']; editor.Elementals.addInput({ row: 1, col: 5, width: 20, placeholder: 'Type here' }); @@ -359,7 +363,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n\n\n'; + editor.Model._ = ['', '', '', '', '', '']; editor.Elementals.addButton({ row: 1, col: 2, label: 'A' }); editor.Elementals.addButton({ row: 2, col: 2, label: 'B' }); editor.Elementals.addButton({ row: 3, col: 2, label: 'C' }); @@ -376,7 +380,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n'; + editor.Model._ = ['', '', '', '']; const id = editor.Elementals.addButton({ row: 1, col: 2, label: 'Test' }); assertEqual(editor.Elementals.elements.length, 1, 'Should have 1 element'); editor.Elementals.removeElement(id); @@ -390,7 +394,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeElementals(editor); - editor.Model.s = '\n\n\n'; + editor.Model._ = ['', '', '', '']; editor.Elementals.addButton({ row: 1, col: 2, label: 'A' }); editor.Elementals.addButton({ row: 2, col: 2, label: 'B' }); assertEqual(editor.Elementals.elements.length, 2, 'Should have 2 elements'); @@ -409,7 +413,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeTUI(editor); - editor.Model.s = '\n\n\n\n\n'; + editor.Model._ = ['', '', '', '', '', '']; // Add in non-sorted order: C at row 3, A at row 1 col 2, B at row 1 col 5 editor.TUI.addButton({ row: 3, col: 1, label: 'C' }); editor.TUI.addButton({ row: 1, col: 2, label: 'A' }); @@ -431,7 +435,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeTUI(editor); - editor.Model.s = '\n\n\n\n\n'; + editor.Model._ = ['', '', '', '', '', '']; editor.TUI.addButton({ row: 1, col: 0, label: 'First' }); editor.TUI.addButton({ row: 2, col: 0, label: 'Second' }); editor.TUI.enabled = true; @@ -453,7 +457,7 @@ function defineExtensionTests() { try { BuffeeTUI(editor); // Create enough lines to fill viewport (10 lines in test editor) - editor.Model.s = '0\n1\n2\n3\n4\n5\n6\n7\n8\n9'; + editor.Model._ = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; editor.TUI.addButton({ row: 5, col: 0, label: 'Mid' }); editor.TUI.enabled = true; editor.render(); @@ -483,7 +487,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeTUI(editor); - editor.Model.s = '\n\n\n'; + editor.Model._ = ['', '', '', '']; const id = editor.TUI.addButton({ row: 1, col: 2, label: ' OK ' }); @@ -498,7 +502,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeTUI(editor); - editor.Model.s = '\n\n\n\n'; + editor.Model._ = ['', '', '', '', '']; const id = editor.TUI.addButton({ row: 1, col: 2, label: 'OK', border: true }); @@ -514,7 +518,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeTUI(editor); - editor.Model.s = '\n\n\n\n'; + editor.Model._ = ['', '', '', '', '']; editor.TUI.addButton({ row: 1, col: 2, label: 'A' }); editor.TUI.addButton({ row: 2, col: 2, label: 'B' }); editor.TUI.enabled = true; @@ -530,7 +534,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeTUI(editor); - editor.Model.s = '\n\n\n'; + editor.Model._ = ['', '', '', '']; editor.TUI.addButton({ row: 1, col: 2, label: 'Test' }); editor.TUI.clear(); assertEqual(editor.TUI.elements.length, 0, 'Should have 0 elements after clear'); @@ -926,7 +930,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHighlights(editor); - editor.Model.s = 'Hello World'; + editor.Model._ = ['Hello World']; const hl = editor.Highlights.create(0, 6, 5); assertTrue(!!hl, 'Should return highlight element'); @@ -969,7 +973,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHighlights(editor); - editor.Model.s = 'Line 0\nLine 1\nLine 2\nLine 3\nLine 4'; + editor.Model._ = ['Line 0', 'Line 1', 'Line 2', 'Line 3', 'Line 4']; editor.Highlights.create(0, 0, 3); editor.Highlights.create(2, 5, 4); @@ -986,7 +990,7 @@ function defineExtensionTests() { const { editor, cleanup } = createTestEditor(); try { BuffeeHighlights(editor); - editor.Model.s = 'Line 0\nLine 1\nLine 2'; + editor.Model._ = ['Line 0', 'Line 1', 'Line 2']; const hl0 = editor.Highlights.create(0, 0, 5); const hl2 = editor.Highlights.create(2, 0, 5); @@ -1081,18 +1085,19 @@ function defineExtensionTests() { // ===== STATUSLINE TESTS ===== extRunner.describe('StatusLine', () => { - extRunner.it('updates originalLineCount immediately when Model.s is set', () => { + extRunner.it('updates line count when Model._ is set and render is called', () => { const { editor, container, cleanup } = createTestEditor(); try { BuffeeStatusLine(editor); const $lineCounter = container.querySelector('.buffee-linecount'); // Set text with 5 lines - editor.Model.s = 'line1\nline2\nline3\nline4\nline5'; + editor.Model._ = ['line1', 'line2', 'line3', 'line4', 'line5']; + editor.render(); - // Should immediately show correct originalLineCount (not 0L) - assertTrue($lineCounter.textContent.includes('originally: 5L'), - 'Should show originally: 5L, got: ' + $lineCounter.textContent); + // Should show correct line count + assertTrue($lineCounter.textContent.includes('5'), + 'Should show 5 lines, got: ' + $lineCounter.textContent); } finally { cleanup(); } @@ -1279,12 +1284,13 @@ function defineExtensionTests() { ) ); - // Verify registration order (innermost first) + // Verify registration order (innermost first, dependencies before dependents) + // TUI internally initializes Highlights, so Highlights registers before TUI assertDeepEqual(decorated.Mode.ext, [ 'History', 'FileLoader', - 'TUI', - 'Highlights' + 'Highlights', + 'TUI' ], 'Decorator pattern should register innermost first'); } finally { cleanup(); diff --git a/test/lib/test-ui.js b/test/lib/test-ui.js index a7abcc18..f711e064 100644 --- a/test/lib/test-ui.js +++ b/test/lib/test-ui.js @@ -184,7 +184,10 @@ } function setEditorContent(text) { - if (dslEditor) dslEditor.Model.s = text; + if (dslEditor) { + dslEditor.Model._ = text.split('\n'); + dslEditor.render(); + } } let jsOutputEditor = null; @@ -258,7 +261,9 @@ // Display generated JavaScript if (jsOutputEditor) { - jsOutputEditor.Model.s = jsOutput; + jsOutputEditor.Syntax.clearCache(); + jsOutputEditor.Model._ = jsOutput.split('\n'); + jsOutputEditor.render(); } const outputEl = document.getElementById('js-output'); outputEl.dataset.plainJs = jsOutput; // Store plain JavaScript for eval @@ -344,7 +349,8 @@ lastCompileHadErrors = false; lastCompileErrors = []; if (jsOutputEditor) { - jsOutputEditor.Model.s = `Error: ${error.message}`; + jsOutputEditor.Model._ = [`Error: ${error.message}`]; + jsOutputEditor.render(); } delete outputEl.dataset.plainJs; outputEl.classList.remove('has-errors'); diff --git a/test/specs/spec-features.dsl b/test/specs/spec-features.dsl index a0674887..04c2a243 100644 --- a/test/specs/spec-features.dsl +++ b/test/specs/spec-features.dsl @@ -38,7 +38,8 @@ expect(gutterWidthPx()).toBeCloseTo(initialWidth); ## should resize gutter based on visible lines ### Gutter based on viewport position, not total lines // Add 15 lines (more than viewport of 10) -fixture.editor.Model.s = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15"; +fixture.editor.Model._ = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]; +fixture.editor.render(); const $gutter = fixture.node.querySelector(".buffee-rail"); // Using tolerance because computed style can differ slightly from actual const gutterWidthPx = () => parseFloat(getComputedStyle($gutter).width); @@ -46,16 +47,17 @@ const initialWidth = gutterWidthPx(); // View shows lines 1-10, largest visible = 10, gutter = 3ch (~43px) expect(initialWidth).toBeCloseTo(43.35); // Scroll down - still 2-digit line numbers visible -fixture.editor.View.set(fixture.editor.View.start + 2); +fixture.editor.View.set(fixture.editor.View.first + 2); expect(gutterWidthPx()).toBeCloseTo(initialWidth); // Scroll back up -fixture.editor.View.set(fixture.editor.View.start - 2); +fixture.editor.View.set(fixture.editor.View.first - 2); expect(gutterWidthPx()).toBeCloseTo(initialWidth); ## should grow gutter when scrolling to 3-digit lines ### Gutter grows from 2 to 3 digits when line 100 is visible // Create 100 lines -fixture.editor.Model.s = Array(100).fill("x").join("\n"); +fixture.editor.Model._ = Array(100).fill("x"); +fixture.editor.render(); const $gutter = fixture.node.querySelector(".buffee-rail"); // Using tolerance because computed style can differ slightly from actual const gutterWidthPx = () => parseFloat(getComputedStyle($gutter).width); diff --git a/test/specs/spec-navigation.dsl b/test/specs/spec-navigation.dsl index bfbc4ee9..e9586cc3 100644 --- a/test/specs/spec-navigation.dsl +++ b/test/specs/spec-navigation.dsl @@ -236,14 +236,14 @@ TYPE "line9" enter TYPE "line10" // Cursor at end of last line, viewport scrolled down -expect(fixture.editor.View.start).toBe(1); +expect(fixture.editor.View.first).toBe(1); // Move cursor to start of first viewport line (line1 = absolute row 1) up 9 times left with meta EXPECT cursor at 1,0 // Alt+Left should scroll viewport up and move cursor to end of line0 left with alt -expect(fixture.editor.View.start).toBe(0); +expect(fixture.editor.View.first).toBe(0); EXPECT cursor at 0,5 ## should scroll viewport down when moveWord at last viewport line @@ -281,7 +281,7 @@ TYPE "line14" // Go back to beginning up 14 times left with meta -expect(fixture.editor.View.start).toBe(0); +expect(fixture.editor.View.first).toBe(0); EXPECT cursor at 0,0 // Navigate to end of line9 (last visible row, with lines 10-14 below) down 9 times @@ -289,7 +289,7 @@ right with meta EXPECT cursor at 9,5 // Alt+Right should scroll viewport down and move cursor to start of line10 right with alt -expect(fixture.editor.View.start).toBe(1); +expect(fixture.editor.View.first).toBe(1); EXPECT cursor at 10,0 ## should not move when moveWord at end of file @@ -315,9 +315,9 @@ EXPECT cursor at 0,0 ### Regression: Up at first line of file does not scroll viewport negative // Empty editor, cursor at 0,0 EXPECT cursor at 0,0 -expect(fixture.editor.View.start).toBe(0); +expect(fixture.editor.View.first).toBe(0); // Press up - should be no-op up -expect(fixture.editor.View.start).toBe(0); +expect(fixture.editor.View.first).toBe(0); EXPECT cursor at 0,0 diff --git a/test/specs/spec-selection.dsl b/test/specs/spec-selection.dsl index 9ad511b9..9e5d4cfb 100644 --- a/test/specs/spec-selection.dsl +++ b/test/specs/spec-selection.dsl @@ -623,7 +623,7 @@ TYPE "line14" // Go to beginning up 14 times left with meta -expect(fixture.editor.View.start).toBe(0); +expect(fixture.editor.View.first).toBe(0); EXPECT cursor at 0,0 // Select down 12 lines (more than viewport of 10) down 12 times with shift @@ -666,7 +666,7 @@ TYPE "after" up 14 times left with meta right 2 times -expect(fixture.editor.View.start).toBe(0); +expect(fixture.editor.View.first).toBe(0); // Select down 5 rows (stays within viewport of 10) down 5 times with shift EXPECT selection at 0,2-5,2 @@ -683,8 +683,8 @@ down 8 times with shift right 2 times with shift EXPECT selection at 0,2-13,4 // View scrolled to keep head visible (row 13) -// View.start = 13 - 10 + 1 = 4, showing rows 4-13 -expect(fixture.editor.View.start).toBe(4); +// View.first = 13 - 10 + 1 = 4, showing rows 4-13 +expect(fixture.editor.View.first).toBe(4); // First edge (row 0) is now ABOVE viewport - not rendered // Row 4 is viewport row 0, rendered as middle line (full width) const $vp0 = fixture.node.querySelectorAll(".buffee-zsel > pre")[0]; diff --git a/web/extensions-profile.html b/web/extensions-profile.html index 7bca7c64..e8a16340 100644 --- a/web/extensions-profile.html +++ b/web/extensions-profile.html @@ -284,7 +284,8 @@

UltraHighCapacity

// Generate test content const testCode = generateCode(1000); - editor.Model.s = testCode; + editor.Model._ = testCode.split('\n'); + editor.render(); const testLine = 'const greeting = "Hello, World!"; // This is a comment'; @@ -323,7 +324,7 @@

UltraHighCapacity

const iterations = getIterations(); const editor = createFreshEditor(); BuffeeElementals(editor); - editor.Model.s = '\n'.repeat(100); + editor.Model._ = Array(101).fill(''); // Profile addButton let start = performance.now(); @@ -366,7 +367,7 @@

UltraHighCapacity

const iterations = getIterations(); const editor = createFreshEditor(); BuffeeTUI(editor); - editor.Model.s = '\n'.repeat(100); + editor.Model._ = Array(101).fill(''); // Profile addButton let start = performance.now(); diff --git a/web/extensions.html b/web/extensions.html index f913a98c..4b1c3078 100644 --- a/web/extensions.html +++ b/web/extensions.html @@ -112,8 +112,12 @@

Sanitize

const editor = BuffeeSanitize(new Buffee(container, config))
 
-// Automatic sanitization on all inserts
-editor.Model.s = "text\twith\ttabs"  // tabs become spaces
+// Automatic sanitization on Model.ins() calls
+editor.Span.ins("text\twith\ttabs")  // tabs become spaces
+
+// Use sanitization utilities when setting Model._ directly
+editor.Model._ = editor.Sanitize.lines(["a\tb", "c\td"])
+editor.render()
 
 // Manual sanitization utilities
 editor.Sanitize.line("hello\tworld")   // "hello    world"
diff --git a/web/themes.html b/web/themes.html
index 9a27559a..a1d6a218 100644
--- a/web/themes.html
+++ b/web/themes.html
@@ -279,10 +279,12 @@ 

Boring

const themeNames = ['eva', 'hn', 'neo', 'star', 'chelsey', 'drak', 'kai', 'nord', 'darkly', 'gruv', 'boring']; + const sampleLines = sampleCode.split('\n'); themeNames.forEach(name => { const el = document.getElementById('ed-' + name); const editor = BuffeeStatusLine(new Buffee(el)); - editor.Model.s = sampleCode; + editor.Model._ = sampleLines.slice(); + editor.render(); }); diff --git a/web/wrappers.html b/web/wrappers.html index 83cde82f..55caa9dc 100644 --- a/web/wrappers.html +++ b/web/wrappers.html @@ -25,7 +25,8 @@

React

useEffect(() => { if (editorRef.current) { - editorRef.current.Model.s = 'Hello, World!'; + editorRef.current.Model._ = ['Hello, World!']; + editorRef.current.render(); } }, []); @@ -40,7 +41,8 @@

Svelte

function handleReady(e) { editor = e.detail; - editor.Model.s = 'Hello, World!'; + editor.Model._ = ['Hello, World!']; + editor.render(); } </script> @@ -52,7 +54,8 @@

Vue

import BuffeeEditor from './vue.js'; function handleReady(editor) { - editor.Model.s = 'Hello, World!'; + editor.Model._ = ['Hello, World!']; + editor.render(); } </script> diff --git a/wrappers/react.jsx b/wrappers/react.jsx index 9f86986e..6f9cf348 100644 --- a/wrappers/react.jsx +++ b/wrappers/react.jsx @@ -8,7 +8,8 @@ * * useEffect(() => { * if (editorRef.current) { - * editorRef.current.Model.s = 'Hello, World!'; + * editorRef.current.Model._ = ['Hello, World!']; + * editorRef.current.render(); * } * }, []); * @@ -67,7 +68,8 @@ const BuffeeEditor = forwardRef(function BuffeeEditor(props, ref) { editorRef.current = editor; if (initialText) { - editor.Model.s = initialText; + editor.Model._ = initialText.split('\n'); + editor.render(); } if (onReady) { diff --git a/wrappers/svelte.svelte b/wrappers/svelte.svelte index 3571035a..ee227a87 100644 --- a/wrappers/svelte.svelte +++ b/wrappers/svelte.svelte @@ -7,7 +7,8 @@ function handleReady(e) { editor = e.detail; - editor.Model.s = 'Hello, World!'; + editor.Model._ = ['Hello, World!']; + editor.render(); } @@ -94,7 +95,8 @@ } if (initialText) { - editor.Model.s = initialText; + editor.Model._ = initialText.split('\n'); + editor.render(); } dispatch('ready', editor); diff --git a/wrappers/vue.js b/wrappers/vue.js index cd390526..a351a713 100644 --- a/wrappers/vue.js +++ b/wrappers/vue.js @@ -8,7 +8,8 @@ * const editorRef = ref(null); * * function handleReady(editor) { - * editor.Model.s = 'Hello, World!'; + * editor.Model._ = ['Hello, World!']; + * editor.render(); * } * * @@ -74,7 +75,8 @@ const BuffeeEditor = defineComponent({ editor.value = ed; if (props.initialText) { - editor.value.Model.s = props.initialText; + editor.value.Model._ = props.initialText.split('\n'); + editor.value.render(); } emit('ready', editor.value); From 9ec02f27497227e2ae8426aee1fdd383a6d929dc Mon Sep 17 00:00:00 2001 From: varrockbank Date: Tue, 13 Jan 2026 07:48:09 +0100 Subject: [PATCH 06/50] refactor(api): s/rows/h s/cols/w in config params --- API.md | 493 ------------------------------ CLAUDE.md | 8 +- CONTRIBUTING.md | 11 +- README.md | 6 +- buffee.js | 26 +- dev/changelog.txt | 3 + dist/buffee.min.js | 2 +- docs/api.md | 8 +- docs/onboarding.md | 12 +- index.html | 6 +- samples/README.md | 2 +- samples/_template.html | 4 +- samples/sample-basic.html | 2 +- samples/sample-conway.html | 2 +- samples/sample-elementals.html | 6 +- samples/sample-gutter-status.html | 12 +- samples/sample-history.html | 4 +- samples/sample-ios.html | 2 +- samples/sample-loader.html | 4 +- samples/sample-matrix.html | 2 +- samples/sample-movie.html | 2 +- samples/sample-readonly.html | 4 +- samples/sample-sizing.html | 82 ++--- samples/sample-syntax.html | 2 +- samples/sample-tui.html | 16 +- samples/sample-undotree.html | 2 +- test/lib/test-extensions.js | 2 +- test/lib/test-runner.js | 2 +- test/lib/test-ui.js | 8 +- web/extensions-profile.html | 2 +- web/getting-started.html | 10 +- wrappers/vue.js | 12 +- 32 files changed, 135 insertions(+), 624 deletions(-) delete mode 100644 API.md diff --git a/API.md b/API.md deleted file mode 100644 index 003187a3..00000000 --- a/API.md +++ /dev/null @@ -1,493 +0,0 @@ -# buffee API Reference - -## Installation - -Include the JavaScript function `Buffee` - -```html - -``` - -## Required HTML Structure - -See `web/template.html` - -## Include the referenced CSSS - -See `style.css` - -## Sizing the Editor - -By default, the editor auto-fits to its container (both rows and columns). Use `rows` and `cols` to fix dimensions. - -| Dimension | Default | Fixed | -|-----------|---------|-------| -| Height | Auto-fits to container | `rows: N` | -| Width | Fills parent (100%) | `cols: N` | - -### Fixed Dimensions - -```javascript -// Fixed 80 columns × 25 rows -new Buffee(el, { rows: 25, cols: 80 }); -``` - -The `cols` option auto-calculates container width to fit exactly N text columns plus gutter. - -### Auto-fit (Default) - -```javascript -// Auto-fit to container (default behavior) -new Buffee(el, {}); -``` - -Requires the container to have defined dimensions: -```css -#editor { width: 100%; height: 100%; } -``` - -### Dimensions - -- Height = `rows` × `lineHeight` pixels (or auto-calculated from container) -- Width = `cols` + gutterCols in `ch` units (or 100% of parent) - -### Auto-fit Details - -Auto-fit is enabled by default. The editor will: -- Calculate how many lines fit based on container height and `lineHeight` -- Update automatically when the container is resized (via ResizeObserver) - -**Container requirements:** -- The container must have a defined height (e.g., `height: 300px` or `height: 100%` with a sized parent) -- Use `overflow: hidden` on the container to clip partial lines - -```html -
-
- ... -
-
-``` - -To disable auto-fit, specify `rows`. - -## Initialize - -```javascript -const editor = new Buffee(document.getElementById('editor'), { - // rows: 20, // Omit to auto-fit, or specify for fixed height - // cols: 80, // Omit to fill parent, or specify for fixed width - s: 4 -}); -``` - -## Constructor Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `rows` | number | (auto) | Fixed visible lines (omit to auto-fit) | -| `cols` | number | (auto) | Fixed text columns (omit to fill parent) | -| `s` | number | `4` | Tab width and indentation (0 = hard tabs) | -| `logger` | function | `console.log` | Custom logger | - ---- - -## Spaces (`editor.Mode.s`) - -Controls tab width and indentation. Soft tabs are enabled by default - tab characters are replaced with spaces. - -```javascript -editor.Mode.s = 4; // Tab key inserts 4 spaces, \t chars become 4 spaces (default) -editor.Mode.s = 2; // Use 2 spaces instead -editor.Mode.s = 0; // Hard tabs (not recommended - cursor positioning may break) -``` - ---- - -## Line Height (`editor.lineHeight`) - -```javascript -editor.lineHeight; // 24 (default, from CSS --buffee-cell) -``` - -Read-only. Returns the line height in pixels, derived from CSS variable `--buffee-cell`. To customize, override in CSS: - -```css -.buffee { --buffee-cell: 20px; } -``` - -**Warning:** Do not modify this value - changing it will cause rendering issues. - ---- - -## Model (`editor.Model`) - -```javascript -// Set content (array of lines) -editor.Model._ = ["Hello", "World"]; - -// Access lines -editor.Model._; // ["Hello", "World"] -editor.Model.last; // 1 -``` - ---- - -## View (`editor.View`) - -```javascript -// Read -editor.View.first; // First visible line (0-based) -editor.View.last; // Last visible line -editor.View.n; // Number of visible lines -editor.View._; // Array of visible line strings - -// Navigate -editor.View.set(5); // Scroll to line index 5 -editor.View.set(99, 25); // Scroll to line index 99, show 25 lines -``` - ---- - -## Span (`editor.Span`) - -```javascript -// Cursor position -editor.Span.cursor({ y: 0, x: 5 }); -editor.Span.select(); // Begin selection (detach head from tail) -editor.Span.bounds(1); // [start, end] in document order -editor.Span.bounds(); // [head, tail] - mutable position objects - -// Selected text -editor.Span._; // Array of selected lines - -// Movement -editor.Span.mvY(1); // Down -editor.Span.mvY(-1); // Up -editor.Span.mvX(1); // Right -editor.Span.mvX(-1); // Left -editor.Span.mvW(1); // Forward word -editor.Span.mvW(-1); // Backward word -editor.Span.mvLn(true); // End of line -editor.Span.mvLn(false); // Start of line (smart home) - -// Editing -editor.Span.ins("text"); -editor.Span.del(); -editor.Span.dent(4); // Indent by 4 spaces -editor.Span.dent(-4); // Unindent by 4 spaces -``` - ---- - -## TUI Extension (`editor.TUI`) - -TUI is an optional extension for interactive terminal-style UI elements. Include the separate script and initialize: - -```html - - -``` - -```javascript -const editor = new Buffee(document.getElementById('editor'), options); -BuffeeTUI(editor); // Initialize TUI extension - -// Now use editor.TUI -editor.TUI.enabled = true; - -// Add button (returns ID) -const id = editor.TUI.addButton({ - row: 5, // Absolute row (0-indexed) - col: 10, // Column (0-indexed) - label: "Button", - border: true, // Optional: draws +--+ border around label - onActivate: (el) => console.log("Clicked!", el) -}); - -// Add prompt (returns ID) -const promptId = editor.TUI.addPrompt({ - row: 8, - col: 2, - width: 30, // Total width including borders - title: "Search", - onActivate: (el) => console.log("Submitted:", el.input) -}); - -// Add scrollbox (returns ID) -const scrollId = editor.TUI.addScrollBox({ - row: 12, - col: 2, - width: 40, - height: 8, // Total height including borders - title: "Logs", - lines: ["Line 1", "Line 2", "..."], - onActivate: (el) => console.log("Selected at offset:", el.scrollOffset) -}); - -// Remove -editor.TUI.removeElement(id); -editor.TUI.clear(); - -// Query -editor.TUI.elements; // Raw array of elements (not a copy - mutations are shared) -editor.TUI.currentElement(); - -// Navigation -editor.TUI.nextElement(); // Move to next (bind to Tab) -editor.TUI.activateElement(); // Trigger callback (bind to Enter) - -// Key handling (element-specific behavior) -editor.TUI.handleKeyDown(key); // Returns true if handled - -// Highlighting (enabled by default) -editor.TUI.setHighlight(true); // Enable -editor.TUI.setHighlight(false); // Disable -``` - -### Element Types - -**Button** (`type: 'button'`) -- Displays label text, optionally with `+-|` border -- Enter key activates (triggers `onActivate`) - -**Prompt** (`type: 'prompt'`) -- Displays input box with box-drawing characters: `┌─┐│└┘` -- Printable ASCII keys insert into input -- Backspace deletes last character -- Enter submits (triggers `onActivate` with `el.input`) - -**ScrollBox** (`type: 'scrollbox'`) -- Displays scrollable content with box-drawing border -- ArrowUp/k scrolls up, ArrowDown/j scrolls down -- Stops when last line is visible at bottom -- Enter activates (triggers `onActivate` with `el.scrollOffset`) - -### Element Properties - -| Property | Type | Description | -|----------|------|-------------| -| `id` | number | Unique identifier | -| `type` | string | `'button'`, `'prompt'`, or `'scrollbox'` | -| `row` | number | Absolute row position | -| `col` | number | Column position | -| `width` | number | Element width in characters | -| `height` | number | Element height in rows | -| `contents` | string[] | Array of rendered lines | -| `input` | string | User input (prompts only) | -| `title` | string | Title (prompts and scrollboxes) | -| `contentLines` | string[] | Content lines (scrollboxes only) | -| `scrollOffset` | number | Current scroll position (scrollboxes only) | -| `onActivate` | function | Callback when activated | - -### Keyboard Binding - -```javascript -element.addEventListener('keydown', (e) => { - if (!editor.TUI.enabled) return; - if (e.key === 'Tab') { - e.preventDefault(); - editor.TUI.nextElement(); - } else { - e.preventDefault(); - editor.TUI.handleKeyDown(e.key); - } -}); -``` - ---- - -## Edit Mode (`editor.editMode`) - -Controls editing and navigation behavior. Three modes are available: - -| Mode | Navigation | Editing | Use Case | -|------|------------|---------|----------| -| `'write'` | Yes | Yes | Default - full editing | -| `'navigate'` | Yes | No | View-only with scrolling | -| `'read'` | No | No | Static display (TUI uses this) | - -```javascript -// Default mode - full editing -editor.editMode = 'write'; - -// Navigate mode - can scroll, no editing -editor.editMode = 'navigate'; - -// Read mode - no navigation or editing -editor.editMode = 'read'; -``` - -### Common Patterns - -**Simple view-only mode:** -```javascript -editor.Model._ = ["Your content here"]; -editor.editMode = 'navigate'; -``` - -**TUI mode** (for interactive elements): -```javascript -editor.Model._ = ["Your content here"]; -editor.TUI.enabled = true; // Sets editMode to 'read' automatically -``` - -**UltraHighCapacity** (for very large files): -```javascript -BuffeeUltraHighCapacity(editor); -editor.UltraHighCapacity.activate(); // Sets editMode to 'navigate' automatically -await editor.UltraHighCapacity.appendLines(["Line 1", "Line 2", ...]); -``` - ---- - -## Tree-sitter Extension (`editor.TreeSitter`) - -Tree-sitter is an optional extension for syntax highlighting. Include the separate script and initialize with a parser and query: - -```html - - -``` - -```javascript -const editor = new Buffee(document.getElementById('editor'), options); -BuffeeTreeSitter(editor, { parser: jsParser, query: jsQuery }); - -// Enable syntax highlighting -editor.TreeSitter.enabled = true; - -// After modifying content, mark as dirty to trigger re-parse -editor.Model._ = ["function hello() { return 'world'; }"]; -editor.TreeSitter.markDirty(); - -// Force immediate re-parse -editor.TreeSitter.reparse(); - -// Access parse tree and captures (read-only) -editor.TreeSitter.tree; // Current parse tree -editor.TreeSitter.captures; // Current query captures -``` - -### CSS Classes - -The extension adds these classes for styling: - -```css -.highlight-function { color: #c678dd; } -.highlight-function-name { color: #61afef; } -.highlight-string { color: #98c379; } -``` - -### Performance - -Tree-sitter rendering is capped at 60fps using a dirty flag pattern. Call `markDirty()` after content changes to trigger re-parsing on the next animation frame. - ---- - -## UltraHighCapacity Extension (`editor.UltraHighCapacity`) - -UltraHighCapacity is an optional extension for loading and viewing very large files (1B+ lines). It compresses lines into gzip chunks and decompresses on-demand for efficient memory usage. - -```html - - -``` - -```javascript -const editor = new Buffee(document.getElementById('editor'), options); -BuffeeUltraHighCapacity(editor); - -// Activate ultra-high-capacity mode (disables editing) -editor.UltraHighCapacity.activate(50000); // 50k lines per chunk - -// Append lines (must use this, not Model.s) -await editor.UltraHighCapacity.appendLines(largeArrayOfLines); - -// Check status -editor.UltraHighCapacity.enabled; // true -editor.UltraHighCapacity.totalLines; // total line count -editor.UltraHighCapacity.chunkCount; // number of compressed chunks - -// Clear all data -editor.UltraHighCapacity.clear(); - -// Deactivate and restore normal mode -editor.UltraHighCapacity.deactivate(); -``` - -### Important Notes - -- **Do not use `Model.s`** in chunked mode - use `appendLines()` instead -- Editing is automatically disabled when activated -- Chunks are loaded asynchronously - "..." placeholders shown while loading -- View can straddle at most 2 chunks (previous + current or current + next) - ---- - -## Extension API - -Internal state is exposed via `editor._` for building extensions. Extensions can hook into the render cycle without buffee needing to know about them. - -```javascript -// Public properties -const { View, Sel, Model, Mode, render, $ } = editor; -// Mode.f - Number of render calls -// Mode.ch - Line height in pixels (from CSS --buffee-cell) -// Mode.cw - Character width in pixels (measured from cursor element) - -// Query DOM elements from $ as needed: -const $e = $.querySelector('.buffee-pane'); -const $textLayer = $.querySelector('.buffee-ztxt'); - -// Wrappable primitives (for extensions like History, Syntax) -const { _insert, _delete } = editor; - -// Render hooks via Mode.renderHooks -const { renderHooks } = editor.Mode; - -// Cursor positions via Span.bounds() -const [head, tail] = editor.Span.bounds(); -``` - -### Render Hooks - -Extensions register callbacks that run after each render: - -```javascript -// Called after text content is set -// rebuilt is non-zero when viewport container changed (resize, initial render) -renderHooks.push(($container, viewport, rebuilt) => { - // Modify textContent, add overlays, update highlights, etc. - if (rebuilt) { - // Container was rebuilt - set up new DOM elements - } -}); -``` - -### Example: Custom Extension - -```javascript -function MyExtension(editor) { - const { renderHooks } = editor.Mode; - const { render } = editor; - - // Register render hook - renderHooks.push(($container, viewport, rebuilt) => { - // Custom rendering logic - }); - - // Expose API on editor instance - editor.MyExtension = { - enable() { editor.Mode.i = 0; render(true); }, - disable() { editor.Mode.i = 1; render(true); } - }; - - return editor; // Decorator pattern: return the editor -} - -// Usage -const editor = new Buffee(el, options); -MyExtension(editor); -editor.MyExtension.enable(); -``` diff --git a/CLAUDE.md b/CLAUDE.md index d60598c8..58589d4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,14 @@ # Claude Instructions for buffee -**Documentation:** `docs/` folder -- `docs/onboarding.md` — Getting started guide -- `docs/api.md` — API surface reference +**Full API reference: `docs/`** — Read these files for complete documentation: +- `docs/api.md` — Model, View, Span, Mode API +- `docs/onboarding.md` — Getting started, sizing, keybindings - `docs/extensions.md` — Extension documentation ## Quick Reference ```javascript -BuffeeStatusLine(new Buffee(el, { rows: 20, cols: 80, s: 4 })); +BuffeeStatusLine(new Buffee(el, { h: 20, w: 80, s: 4 })); editor.Model._ = ["line1", "line2"]; // Set content (array of lines) editor.View.set(5); // Scroll to line index 5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a83a6ec2..43384fbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,19 @@ # Commits -## Scopes: +## Directory structure web - github pages related stuff test - test related stuff - -## Files - assets - for github pages assets +samples - kitchen sink samples +theme - css themess +wrappers - Component wrappers in JavaScript UI frameworks +resources - test files, mostly for load testing ## Extensions 1. Add to extensions directory -2. update web/extensions.html +2. update web/extensions.html ## Distributable diff --git a/README.md b/README.md index 9e5c50f5..07d6324b 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,9 @@ const editor = new Buffee(document.getElementById("editor"), {}); Editor auto-fits to its container size. For fixed dimensions: ```javascript -new Buffee(el, { rows: 20 }); // Fixed row count -new Buffee(el, { cols: 80 }); // Fixed column width -new Buffee(el, { rows: 20, cols: 80 }); // Both fixed +new Buffee(el, { h: 20 }); // Fixed row count +new Buffee(el, { w: 80 }); // Fixed column width +new Buffee(el, { h: 20, w: 80 }); // Both fixed ``` Container should have explicit height inherit some percentage from parent. diff --git a/buffee.js b/buffee.js index db32aa71..5235eb5b 100644 --- a/buffee.js +++ b/buffee.js @@ -9,16 +9,16 @@ * @constructor * @param {HTMLElement} $ - Container element * @param {Object} [config={}] - Configuration options - * @param {number} [config.rows] - Fixed visible lines (omit to auto-fit) - * @param {number} [config.cols] - Fixed text columns (omit to fill parent) + * @param {number} [config.h] - Fixed visible lines (omit to auto-fit) + * @param {number} [config.w] - Fixed text columns (omit to fill parent) * @param {number} [config.s=4] - Spaces per tab/indentation * @example - * const editor = new Buffee(document.getElementById('editor'), { rows: 25 }); + * const editor = new Buffee(document.getElementById('editor'), { h: 25 }); * editor.Model._ = ['Hello, World!']; * editor.render(); */ -function Buffee($, { rows, cols, s = 4 } = {}) { - this.v = '15.4.0-alpha.1'; +function Buffee($, { h, w, s = 4 } = {}) { + this.v = '15.5.0-alpha.1'; this.$ = $; const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u; // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative). @@ -308,8 +308,8 @@ function Buffee($, { rows, cols, s = 4 } = {}) { first: 0, /** @type {number} Number of visible lines */ n: 0, - /** @type {number} Number of DOM line containers. +1 if auto-fit (no rows specified) */ - get N() { return this.n + !rows; }, + /** @type {number} Number of DOM line containers. +1 if auto-fit (no h specified) */ + get N() { return this.n + !h; }, /** * Index of the last visible line. @@ -347,7 +347,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) { if ($rail) { const railCols = Math.max(railInit, (View.first + View.N).toString().length) + railPad; $rail.style.width = railCols + 'ch'; - if (cols) $pane.style.width = `calc(${railCols + cols}ch + ${padding * 4}px)`; + if (w) $pane.style.width = `calc(${railCols + w}ch + ${padding * 4}px)`; } render(delta); }; @@ -388,14 +388,14 @@ function Buffee($, { rows, cols, s = 4 } = {}) { Mode.sub.forEach(hook => hook($lines, View, delta)); } - // Set container width if cols specified + // Set container width if w specified // Width = rail(ch) + lines(ch) + margins(px): rail has margin*2, lines has margin*2 - cols && !$rail && ($pane.style.width = `calc(${cols}ch + ${padding * 2}px)`); - // Set container height if rows specified (don't use flex: 1). TODO: perhaps can just set on parent - rows && viewportLayers.forEach(([, , p]) => p && (p.style.height = rows * ch + 'px')); + w && !$rail && ($pane.style.width = `calc(${w}ch + ${padding * 2}px)`); + // Set container height if h specified (don't use flex: 1). TODO: perhaps can just set on parent + h && viewportLayers.forEach(([, , p]) => p && (p.style.height = h * ch + 'px')); // Initial sizing render const resize = delta => {View.n += delta, RENDER(delta)}; - rows ? resize(rows) : new ResizeObserver(() => {lRect = $lines.getBoundingClientRect(); resize(Math.floor($pane.clientHeight / ch) - View.n)}).observe($pane); + h ? resize(h) : new ResizeObserver(() => {lRect = $lines.getBoundingClientRect(); resize(Math.floor($pane.clientHeight / ch) - View.n)}).observe($pane); // Reading clipboard from the keydown listener involves a different security model. $lines.addEventListener('paste', e => { diff --git a/dev/changelog.txt b/dev/changelog.txt index 3a232300..3c2d192a 100644 --- a/dev/changelog.txt +++ b/dev/changelog.txt @@ -1,5 +1,8 @@ * Project Devlog +** 15.5.0-alpha.1 [2026-01-13] gz: 2585 / 2.52 KB (-4), br: 2388 / 2.33 KB (-4), min: 5807 / 5.67 KB (-6) +- config: fixed width/height specified with 'w' and 'h' instead 'cols' and 'rows' + ** 15.4.0-alpha.1 [2026-01-13] gz: 2589 / 2.53 KB (-17), br: 2393 / 2.34 KB (-12), min: 5813 / 5.68 KB (-35) - api: remove Model.s = "foobar". Delegate to user to render on Model._ = ["foobar"] diff --git a/dist/buffee.min.js b/dist/buffee.min.js index 2f5634d3..d2c2e037 100644 --- a/dist/buffee.min.js +++ b/dist/buffee.min.js @@ -1 +1 @@ -function Buffee(t,{rows:e,cols:s,s:i=4}={}){this.v="15.4.0-alpha.1",this.$=t;const n=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,f,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[d,u,p,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=u.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.first+e]??null],[_,(t,e)=>t.textContent=L.first+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.last?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(i?s:0)){let t=c.x;const r=i?()=>tt>0,h=i?()=>t++:()=>t--;if(n.test(e[t])){for(;r()&&n.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(i?c.y0)&&(c.x=i?0:E._[--c.y].length,i&&++c.y>L.last?L.set(c.y-L.n+1):!i&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.last?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let i=e.y;i<=s.y;i++){const n=E._[i];if(t>0)E._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);E._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:i,i:1,f:0,ch:a,cw:p.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get last(){return this._.length-1},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},L=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,E.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,E.last)),C(s)},get _(){return E._.slice(this.first,this.last+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(f,(L.first+L.N).toString().length)+x;_.style.width=t+"ch",s&&(d.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),i=Math.min(L.first+L.n,s.y+1);for(let e=Math.max(L.first,t.y);e=0&&ns?l-s:0))/D.cw)*D.cw}}p.style.left=e+"ch",D.sub.forEach(e=>e(u,L,t))};s&&!_&&(d.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=u.getBoundingClientRect(),R(Math.floor(d.clientHeight/a)-L.n)}).observe(d),u.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),u.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};u.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||i?M.dent(i?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&M.dir?M.cursor():i&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!i&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.last));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.last?L.set(e-L.n+1):$()}else i&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file +function Buffee(t,{h:e,w:s,s:i=4}={}){this.v="15.5.0-alpha.1",this.$=t;const n=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,f,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[d,u,p,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=u.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.first+e]??null],[_,(t,e)=>t.textContent=L.first+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.last?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(i?s:0)){let t=c.x;const r=i?()=>tt>0,h=i?()=>t++:()=>t--;if(n.test(e[t])){for(;r()&&n.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(i?c.y0)&&(c.x=i?0:E._[--c.y].length,i&&++c.y>L.last?L.set(c.y-L.n+1):!i&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.last?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let i=e.y;i<=s.y;i++){const n=E._[i];if(t>0)E._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);E._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:i,i:1,f:0,ch:a,cw:p.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get last(){return this._.length-1},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},L=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,E.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,E.last)),C(s)},get _(){return E._.slice(this.first,this.last+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(f,(L.first+L.N).toString().length)+x;_.style.width=t+"ch",s&&(d.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),i=Math.min(L.first+L.n,s.y+1);for(let e=Math.max(L.first,t.y);e=0&&ns?l-s:0))/D.cw)*D.cw}}p.style.left=e+"ch",D.sub.forEach(e=>e(u,L,t))};s&&!_&&(d.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=u.getBoundingClientRect(),R(Math.floor(d.clientHeight/a)-L.n)}).observe(d),u.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),u.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};u.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||i?M.dent(i?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&M.dir?M.cursor():i&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!i&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.last));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.last?L.set(e-L.n+1):$()}else i&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 578f177d..21e8a57a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3,14 +3,14 @@ ## Instantiation ```javascript -const editor = new Buffee(element, { rows, cols, s }) +const editor = new Buffee(element, { h, w, s }) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| -| `rows` | number | auto | Fixed visible lines | -| `cols` | number | auto | Fixed text columns | -| `s` | number | 4 | Tab width (0 = hard tabs) | +| `h` | number | auto | Fixed visible lines | +| `w` | number | auto | Fixed text columns | +| `s` | number | 4 | Tab width (0 = hard tabs) | ## Top-level properties diff --git a/docs/onboarding.md b/docs/onboarding.md index ca2d6137..b49bf3df 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -40,9 +40,9 @@ See `web/template.html` for the required HTML structure: ```javascript const editor = new Buffee(document.querySelector('.buffee'), { - rows: 20, // Optional: fixed height (omit to auto-fit) - cols: 80, // Optional: fixed width (omit to fill parent) - s: 4 // Tab width (default: 4) + h: 20, // Optional: fixed height (omit to auto-fit) + w: 80, // Optional: fixed width (omit to fill parent) + s: 4 // Tab width (default: 4) }); // Optional: add status line updates @@ -60,8 +60,8 @@ editor.render(); // Trigger re-render after setting content | Option | Default | Description | |--------|---------|-------------| -| `rows` | auto | Fixed visible lines (omit to auto-fit to container) | -| `cols` | auto | Fixed text columns (omit to fill parent width) | +| `h` | auto | Fixed visible lines (omit to auto-fit to container) | +| `w` | auto | Fixed text columns (omit to fill parent width) | ### Auto-fit (Default) @@ -74,7 +74,7 @@ The editor auto-fits to its container. Requires the container to have defined di ### Fixed Dimensions ```javascript -new Buffee(el, { rows: 25, cols: 80 }); +new Buffee(el, { h: 25, w: 80 }); ``` ## Keybindings diff --git a/index.html b/index.html index c215191a..1d2e1776 100644 --- a/index.html +++ b/index.html @@ -175,7 +175,7 @@

HackerNews Front Page

diff --git a/samples/sample-history.html b/samples/sample-history.html index aaf62c21..2d1b5ee9 100644 --- a/samples/sample-history.html +++ b/samples/sample-history.html @@ -72,13 +72,13 @@

With History

* * */ @@ -28,9 +28,9 @@ const BuffeeEditor = defineComponent({ props: { /** Fixed number of visible lines */ - rows: { type: Number, default: undefined }, + h: { type: Number, default: undefined }, /** Fixed number of text columns */ - cols: { type: Number, default: undefined }, + w: { type: Number, default: undefined }, /** Spaces per tab */ spaces: { type: Number, default: 4 }, /** Theme name (e.g., 'eva', 'nord', 'gruv') */ @@ -60,9 +60,9 @@ const BuffeeEditor = defineComponent({ if (!container.value || typeof Buffee === 'undefined') return; const config = { - rows: props.rows, - cols: props.cols, - spaces: props.spaces + h: props.h, + w: props.w, + s: props.spaces }; let ed = new Buffee(container.value, config); From 90c105d61ab41ea00c719e4e0557c77c1e51a11f Mon Sep 17 00:00:00 2001 From: varrockbank Date: Tue, 13 Jan 2026 07:50:31 +0100 Subject: [PATCH 07/50] perf: optimize hotpath, regex for move word only when used --- buffee.js | 13 ++++++------- dev/changelog.txt | 3 +++ dist/buffee.min.js | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/buffee.js b/buffee.js index 5235eb5b..a96e20e9 100644 --- a/buffee.js +++ b/buffee.js @@ -7,10 +7,10 @@ /** * Creates a new Buffee editor instance bound to $. * @constructor - * @param {HTMLElement} $ - Container element - * @param {Object} [config={}] - Configuration options - * @param {number} [config.h] - Fixed visible lines (omit to auto-fit) - * @param {number} [config.w] - Fixed text columns (omit to fill parent) + * @param {HTMLElement} $ - Container element + * @param {Object} [config={} ] - Configuration options + * @param {number} [config.h ] - Fixed visible lines (omit to auto-fit) + * @param {number} [config.w ] - Fixed text columns (omit to fill parent) * @param {number} [config.s=4] - Spaces per tab/indentation * @example * const editor = new Buffee(document.getElementById('editor'), { h: 25 }); @@ -18,9 +18,8 @@ * editor.render(); */ function Buffee($, { h, w, s = 4 } = {}) { - this.v = '15.5.0-alpha.1'; + this.v = '15.5.1-alpha.1'; this.$ = $; - const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u; // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative). // This allows selections to span beyond the viewport. // In case where we have cursor, we want head === tail. @@ -96,7 +95,7 @@ function Buffee($, { h, w, s = 4 } = {}) { /** * Moves cursor by word in direction. dir: +1 forward, -1 backward. Future: other values for multi-word jumps. */ - mvW(dir) { + mvW(dir, spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u) { const s = Model._[head.y], n = s.length, fwd = dir > 0; if (head.x !== (fwd ? n : 0)) { // Move within line diff --git a/dev/changelog.txt b/dev/changelog.txt index 3c2d192a..51c1af2a 100644 --- a/dev/changelog.txt +++ b/dev/changelog.txt @@ -1,5 +1,8 @@ * Project Devlog +** 15.5.1-alpha.1 [2026-01-13] gz: 2588 / 2.53 KB (+3), br: 2385 / 2.33 KB (-3), min: 5807 / 5.67 KB (0) +- perf: optimize hotpath, isolate regex for move word until they are needed + ** 15.5.0-alpha.1 [2026-01-13] gz: 2585 / 2.52 KB (-4), br: 2388 / 2.33 KB (-4), min: 5807 / 5.67 KB (-6) - config: fixed width/height specified with 'w' and 'h' instead 'cols' and 'rows' diff --git a/dist/buffee.min.js b/dist/buffee.min.js index d2c2e037..0a8b6117 100644 --- a/dist/buffee.min.js +++ b/dist/buffee.min.js @@ -1 +1 @@ -function Buffee(t,{h:e,w:s,s:i=4}={}){this.v="15.5.0-alpha.1",this.$=t;const n=/\s/,l=/[\p{L}\p{Nd}_]/u,r={y:0,x:0};let c={y:0,x:0},h=c,o=c.x;const[a,y,f,x]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[d,u,p,g,_,m,v]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let w=u.getBoundingClientRect();const b=[[m,(t,e)=>t.textContent=E._[L.first+e]??null],[_,(t,e)=>t.textContent=L.first+e+1],[v,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),M=this.Span={bounds:t=>t&&M.dir>0?[h,c]:[c,h],mvY(t,e){if(t>0?c.y0){const s=E._[t>0?++c.y:--c.y].length;c.x=e?t>0?0:s:Math.min(o,s),e&&(o=c.x),c.yL.last?L.set(t>0?c.y-L.n+1:c.y):$()}},mvX(t){const e=t>0;(e?c.x0&&s0;if(c.x!==(i?s:0)){let t=c.x;const r=i?()=>tt>0,h=i?()=>t++:()=>t--;if(n.test(e[t])){for(;r()&&n.test(e[t]);)h();for(;r()&&l.test(e[t]);)h()}else if(l.test(e[t]))for(;r()&&l.test(e[t]);)h();else{const s=e[t];for(h();r()&&e[t]===s;)h()}c.x=t,$()}else(i?c.y0)&&(c.x=i?0:E._[--c.y].length,i&&++c.y>L.last?L.set(c.y-L.n+1):!i&&c.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(c.y=t.y,c.x=t.x),h.y=c.y,h.x=c.x,c=h},select(){c=r,c.y=h.y,c.x=h.x},ins(t){if(this.dir){const[e,s]=M.bounds(1);E.del(e.y,e.x,s.y,s.x+(this.dir>0)),E.ins(e.y,e.x,t),c.y=e.y,t.length>1?(c.y+=t.length-1,c.x=t[t.length-1].length):c.x=e.x+(t[0]?.length||0),this.cursor()}else E.ins(c.y,c.x,t),t.length>1?(c.y+=t.length-1,o=c.x=t[t.length-1].length):o=c.x+=t[0]?.length||0;c.y>L.last?L.set(c.y-L.n+1):$()},del(){this.dir?this.ins([""]):c.x>0?(E.del(c.y,c.x-1,c.y,c.x),c.x--,$()):c.y>0&&(c.x=E._[c.y-1].length,E.del(c.y-1,c.x,c.y,0),--c.y0&&!this.dir)return;const[e,s]=M.bounds(1);for(let i=e.y;i<=s.y;i++){const n=E._[i];if(t>0)E._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);E._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),$()}},D=this.Mode={s:i,i:1,f:0,ch:a,cw:p.getBoundingClientRect().width,sub:[],ext:[]},E=this.Model={_:[""],get last(){return this._.length-1},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},L=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,E.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,E.last)),C(s)},get _(){return E._.slice(this.first,this.last+1)}},C=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)b.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&b.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)b.forEach(([t])=>t.pop()?.remove())}if(_){const t=Math.max(f,(L.first+L.N).toString().length)+x;_.style.width=t+"ch",s&&(d.style.width=`calc(${t+s}ch + ${4*y}px)`)}$(t)},$=this.render=(t=0)=>{D.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(D.i>=0){const[t,s]=M.bounds(1),i=Math.min(L.first+L.n,s.y+1);for(let e=Math.max(L.first,t.y);e=0&&ns?l-s:0))/D.cw)*D.cw}}p.style.left=e+"ch",D.sub.forEach(e=>e(u,L,t))};s&&!_&&(d.style.width=`calc(${s}ch + ${2*y}px)`),e&&b.forEach(([,,t])=>t&&(t.style.height=e*a+"px"));const R=t=>{L.n+=t,C(t)};e?R(e):new ResizeObserver(()=>{w=u.getBoundingClientRect(),R(Math.floor(d.clientHeight/a)-L.n)}).observe(d),u.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&M.ins(e.split("\n"))}),g.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n"))}),g.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",M._.join("\n")),M.del(),u.focus({preventScroll:!0})});const S={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};u.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{g.focus({preventScroll:!0}),g.select()},x:()=>{g.focus({preventScroll:!0}),g.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{M.del()},Enter:()=>{M.ins(["",""])},Tab:()=>{t.preventDefault(),M.dir||i?M.dent(i?-D.s:D.s):M.ins([" ".repeat(D.s)])}},r=S[s]||0;if(r){if(t.preventDefault(),D.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&M.dir?M.cursor():i&&!M.dir&&M.select(),r%2&&(e?M.mvLn(s>0):M.mvW(s));else if(!i&&M.dir)if(r%2)M.cursor(M.bounds(1)[s>0|0]),$();else{const t=M.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,E.last));o=Math.min(t.x,E._[e].length),M.cursor({y:e,x:o}),eL.last?L.set(e-L.n+1):$()}else i&&!M.dir&&M.select(),M[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():D.i>0&&(" "===s&&t.preventDefault(),M.ins([s])):l[s]&&D.i>=1&&l[s]()})} \ No newline at end of file +function Buffee(t,{h:e,w:s,s:i=4}={}){this.v="15.5.1-alpha.1",this.$=t;const n={y:0,x:0};let l={y:0,x:0},r=l,c=l.x;const[h,o,a,y]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,x,d,u,p,g,_]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let m=x.getBoundingClientRect();const v=[[g,(t,e)=>t.textContent=M._[D.first+e]??null],[p,(t,e)=>t.textContent=D.first+e+1],[_,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),w=this.Span={bounds:t=>t&&w.dir>0?[r,l]:[l,r],mvY(t,e){if(t>0?l.y0){const s=M._[t>0?++l.y:--l.y].length;l.x=e?t>0?0:s:Math.min(c,s),e&&(c=l.x),l.yD.last?D.set(t>0?l.y-D.n+1:l.y):L()}},mvX(t){const e=t>0;(e?l.x0&&s0;if(l.x!==(r?n:0)){let t=l.x;const c=r?()=>tt>0,h=r?()=>t++:()=>t--;if(e.test(i[t])){for(;c()&&e.test(i[t]);)h();for(;c()&&s.test(i[t]);)h()}else if(s.test(i[t]))for(;c()&&s.test(i[t]);)h();else{const e=i[t];for(h();c()&&i[t]===e;)h()}l.x=t,L()}else(r?l.y0)&&(l.x=r?0:M._[--l.y].length,r&&++l.y>D.last?D.set(l.y-D.n+1):!r&&l.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(l.y=t.y,l.x=t.x),r.y=l.y,r.x=l.x,l=r},select(){l=n,l.y=r.y,l.x=r.x},ins(t){if(this.dir){const[e,s]=w.bounds(1);M.del(e.y,e.x,s.y,s.x+(this.dir>0)),M.ins(e.y,e.x,t),l.y=e.y,t.length>1?(l.y+=t.length-1,l.x=t[t.length-1].length):l.x=e.x+(t[0]?.length||0),this.cursor()}else M.ins(l.y,l.x,t),t.length>1?(l.y+=t.length-1,c=l.x=t[t.length-1].length):c=l.x+=t[0]?.length||0;l.y>D.last?D.set(l.y-D.n+1):L()},del(){this.dir?this.ins([""]):l.x>0?(M.del(l.y,l.x-1,l.y,l.x),l.x--,L()):l.y>0&&(l.x=M._[l.y-1].length,M.del(l.y-1,l.x,l.y,0),--l.y0&&!this.dir)return;const[e,s]=w.bounds(1);for(let i=e.y;i<=s.y;i++){const n=M._[i];if(t>0)M._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);M._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),L()}},b=this.Mode={s:i,i:1,f:0,ch:h,cw:d.getBoundingClientRect().width,sub:[],ext:[]},M=this.Model={_:[""],get last(){return this._.length-1},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},D=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,M.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,M.last)),E(s)},get _(){return M._.slice(this.first,this.last+1)}},E=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)v.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&v.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)v.forEach(([t])=>t.pop()?.remove())}if(p){const t=Math.max(a,(D.first+D.N).toString().length)+y;p.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*o}px)`)}L(t)},L=this.render=(t=0)=>{b.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(b.i>=0){const[t,s]=w.bounds(1),i=Math.min(D.first+D.n,s.y+1);for(let e=Math.max(D.first,t.y);e=0&&ns?r-s:0))/b.cw)*b.cw}}d.style.left=e+"ch",b.sub.forEach(e=>e(x,D,t))};s&&!p&&(f.style.width=`calc(${s}ch + ${2*o}px)`),e&&v.forEach(([,,t])=>t&&(t.style.height=e*h+"px"));const C=t=>{D.n+=t,E(t)};e?C(e):new ResizeObserver(()=>{m=x.getBoundingClientRect(),C(Math.floor(f.clientHeight/h)-D.n)}).observe(f),x.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&w.ins(e.split("\n"))}),u.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",w._.join("\n"))}),u.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",w._.join("\n")),w.del(),x.focus({preventScroll:!0})});const $={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};x.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{u.focus({preventScroll:!0}),u.select()},x:()=>{u.focus({preventScroll:!0}),u.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{w.del()},Enter:()=>{w.ins(["",""])},Tab:()=>{t.preventDefault(),w.dir||i?w.dent(i?-b.s:b.s):w.ins([" ".repeat(b.s)])}},r=$[s]||0;if(r){if(t.preventDefault(),b.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&w.dir?w.cursor():i&&!w.dir&&w.select(),r%2&&(e?w.mvLn(s>0):w.mvW(s));else if(!i&&w.dir)if(r%2)w.cursor(w.bounds(1)[s>0|0]),L();else{const t=w.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,M.last));c=Math.min(t.x,M._[e].length),w.cursor({y:e,x:c}),eD.last?D.set(e-D.n+1):L()}else i&&!w.dir&&w.select(),w[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():b.i>0&&(" "===s&&t.preventDefault(),w.ins([s])):l[s]&&b.i>=1&&l[s]()})} \ No newline at end of file From 428173817e40e99ad2714a520e31d2f7f4e31cb5 Mon Sep 17 00:00:00 2001 From: varrockbank Date: Tue, 13 Jan 2026 08:15:14 +0100 Subject: [PATCH 08/50] api: move render/RENDER to View namespace, convert api.md to api.txt - Public API changed: editor.View.render() and editor.View.RENDER() - Internal implementation unchanged (render/RENDER remain as const) - Updated all extensions to destructure render from editor.View - Converted docs/api.md to docs/api.txt (ASCII plaintext format) - Updated all references to api.md throughout codebase - Bump version to 15.6.0-alpha.1 --- CLAUDE.md | 3 +- README.md | 2 +- buffee.js | 8 +-- dev/changelog.txt | 4 ++ dist/buffee.min.js | 2 +- docs/api.md | 95 ------------------------------- docs/api.txt | 86 ++++++++++++++++++++++++++++ docs/onboarding.md | 2 +- extensions/_template.js | 5 +- extensions/elementals.js | 3 +- extensions/fileloader.js | 3 +- extensions/history.js | 2 +- extensions/ios.js | 2 +- extensions/treesitter.js | 3 +- extensions/tui.js | 3 +- extensions/ultrahighcapacity.js | 3 +- index.html | 4 +- samples/_template.html | 2 +- samples/sample-basic.html | 2 +- samples/sample-conway.html | 2 +- samples/sample-gutter-status.html | 12 ++-- samples/sample-history.html | 4 +- samples/sample-ios.html | 2 +- samples/sample-loader.html | 2 +- samples/sample-matrix.html | 2 +- samples/sample-movie.html | 6 +- samples/sample-readonly.html | 2 +- samples/sample-sizing.html | 16 +++--- samples/sample-syntax.html | 6 +- samples/sample-undotree.html | 2 +- test/lib/test-extensions.js | 16 +++--- test/lib/test-ui.js | 6 +- test/specs/spec-features.dsl | 4 +- web/extensions-profile.html | 4 +- web/extensions.html | 2 +- web/getting-started.html | 2 +- web/navigation.html | 2 +- web/themes.html | 2 +- web/wrappers.html | 6 +- wrappers/react.jsx | 4 +- wrappers/svelte.svelte | 4 +- wrappers/vue.js | 4 +- 42 files changed, 174 insertions(+), 172 deletions(-) delete mode 100644 docs/api.md create mode 100644 docs/api.txt diff --git a/CLAUDE.md b/CLAUDE.md index 58589d4d..7fb40382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Claude Instructions for buffee **Full API reference: `docs/`** — Read these files for complete documentation: -- `docs/api.md` — Model, View, Span, Mode API +- `docs/api.txt` — Model, View, Span, Mode API - `docs/onboarding.md` — Getting started, sizing, keybindings - `docs/extensions.md` — Extension documentation @@ -11,6 +11,7 @@ BuffeeStatusLine(new Buffee(el, { h: 20, w: 80, s: 4 })); editor.Model._ = ["line1", "line2"]; // Set content (array of lines) +editor.View.render(); // Render after content changes editor.View.set(5); // Scroll to line index 5 editor.Span.ins(["text"]); // Insert at cursor editor.Span.cursor({y:0,x:0}); // Move cursor diff --git a/README.md b/README.md index 07d6324b..04430744 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ anchor and the head/dot are the same. Text editing operations are defined relati The controller are keyboard event handlers which route to operations on the selection. In the future, the basic controller will be refactored out of Buffee.js as an Extension such that you will have to bring-your-own controller by default. e.g. a "vim normal mode controller". -See: [API Reference](docs/api.md) | [Getting Started](docs/onboarding.md) +See: [API Reference](docs/api.txt) | [Getting Started](docs/onboarding.md) ## Extensibility diff --git a/buffee.js b/buffee.js index a96e20e9..d6e34627 100644 --- a/buffee.js +++ b/buffee.js @@ -15,10 +15,10 @@ * @example * const editor = new Buffee(document.getElementById('editor'), { h: 25 }); * editor.Model._ = ['Hello, World!']; - * editor.render(); + * editor.View.render(); */ function Buffee($, { h, w, s = 4 } = {}) { - this.v = '15.5.1-alpha.1'; + this.v = '15.6.0-alpha.1'; this.$ = $; // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative). // This allows selections to span beyond the viewport. @@ -336,7 +336,7 @@ function Buffee($, { h, w, s = 4 } = {}) { }; // Add / remove lines, selections, rails as row changes - const RENDER = this.RENDER = delta => { + const RENDER = View.RENDER = delta => { if (delta) { let d = delta; for (; d > 0; d-- ) viewportLayers.forEach(([a, f]) => a.push(f.appendChild(document.createElement('pre')))); @@ -354,7 +354,7 @@ function Buffee($, { h, w, s = 4 } = {}) { /** * Renders the editor viewport, selection, cursor, and calls extension hooks. */ - const render = this.render = (delta = 0) => { + const render = View.render = (delta = 0) => { Mode.f++; // Update contents of line containers (reset to clean state) diff --git a/dev/changelog.txt b/dev/changelog.txt index 51c1af2a..b0285517 100644 --- a/dev/changelog.txt +++ b/dev/changelog.txt @@ -1,5 +1,9 @@ * Project Devlog +** 15.6.0-alpha.1 [2026-01-13] gz: 2587 / 2.53 KB (-1), br: 2387 / 2.33 KB (+2), min: 5801 / 5.67 KB (-6) +- api: move render/RENDER to View namespace (editor.View.render(), editor.View.RENDER()) +- docs: convert api.md to api.txt (ASCII plaintext format) + ** 15.5.1-alpha.1 [2026-01-13] gz: 2588 / 2.53 KB (+3), br: 2385 / 2.33 KB (-3), min: 5807 / 5.67 KB (0) - perf: optimize hotpath, isolate regex for move word until they are needed diff --git a/dist/buffee.min.js b/dist/buffee.min.js index 0a8b6117..6de35546 100644 --- a/dist/buffee.min.js +++ b/dist/buffee.min.js @@ -1 +1 @@ -function Buffee(t,{h:e,w:s,s:i=4}={}){this.v="15.5.1-alpha.1",this.$=t;const n={y:0,x:0};let l={y:0,x:0},r=l,c=l.x;const[h,o,a,y]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,x,d,u,p,g,_]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let m=x.getBoundingClientRect();const v=[[g,(t,e)=>t.textContent=M._[D.first+e]??null],[p,(t,e)=>t.textContent=D.first+e+1],[_,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),w=this.Span={bounds:t=>t&&w.dir>0?[r,l]:[l,r],mvY(t,e){if(t>0?l.y0){const s=M._[t>0?++l.y:--l.y].length;l.x=e?t>0?0:s:Math.min(c,s),e&&(c=l.x),l.yD.last?D.set(t>0?l.y-D.n+1:l.y):L()}},mvX(t){const e=t>0;(e?l.x0&&s0;if(l.x!==(r?n:0)){let t=l.x;const c=r?()=>tt>0,h=r?()=>t++:()=>t--;if(e.test(i[t])){for(;c()&&e.test(i[t]);)h();for(;c()&&s.test(i[t]);)h()}else if(s.test(i[t]))for(;c()&&s.test(i[t]);)h();else{const e=i[t];for(h();c()&&i[t]===e;)h()}l.x=t,L()}else(r?l.y0)&&(l.x=r?0:M._[--l.y].length,r&&++l.y>D.last?D.set(l.y-D.n+1):!r&&l.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(l.y=t.y,l.x=t.x),r.y=l.y,r.x=l.x,l=r},select(){l=n,l.y=r.y,l.x=r.x},ins(t){if(this.dir){const[e,s]=w.bounds(1);M.del(e.y,e.x,s.y,s.x+(this.dir>0)),M.ins(e.y,e.x,t),l.y=e.y,t.length>1?(l.y+=t.length-1,l.x=t[t.length-1].length):l.x=e.x+(t[0]?.length||0),this.cursor()}else M.ins(l.y,l.x,t),t.length>1?(l.y+=t.length-1,c=l.x=t[t.length-1].length):c=l.x+=t[0]?.length||0;l.y>D.last?D.set(l.y-D.n+1):L()},del(){this.dir?this.ins([""]):l.x>0?(M.del(l.y,l.x-1,l.y,l.x),l.x--,L()):l.y>0&&(l.x=M._[l.y-1].length,M.del(l.y-1,l.x,l.y,0),--l.y0&&!this.dir)return;const[e,s]=w.bounds(1);for(let i=e.y;i<=s.y;i++){const n=M._[i];if(t>0)M._[i]=" ".repeat(t)+n;else{const l=i===e.y?e:i===s.y?s:null;if(l){const e=n.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,n.slice(0,l.x).search(/[^ ]|$/)+e);M._[i]=n.slice(s),e0&&(e.x+=t,s.x+=t),L()}},b=this.Mode={s:i,i:1,f:0,ch:h,cw:d.getBoundingClientRect().width,sub:[],ext:[]},M=this.Model={_:[""],get last(){return this._.length-1},ins(t,e,s){const i=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=i:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+i)},del(t,e,s,i){this._[t]=this._[t].slice(0,e)+this._[s].slice(i),t!==s&&this._.splice(t+1,s-t)}},D=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,M.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,M.last)),E(s)},get _(){return M._.slice(this.first,this.last+1)}},E=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)v.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&v.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)v.forEach(([t])=>t.pop()?.remove())}if(p){const t=Math.max(a,(D.first+D.N).toString().length)+y;p.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*o}px)`)}L(t)},L=this.render=(t=0)=>{b.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(b.i>=0){const[t,s]=w.bounds(1),i=Math.min(D.first+D.n,s.y+1);for(let e=Math.max(D.first,t.y);e=0&&ns?r-s:0))/b.cw)*b.cw}}d.style.left=e+"ch",b.sub.forEach(e=>e(x,D,t))};s&&!p&&(f.style.width=`calc(${s}ch + ${2*o}px)`),e&&v.forEach(([,,t])=>t&&(t.style.height=e*h+"px"));const C=t=>{D.n+=t,E(t)};e?C(e):new ResizeObserver(()=>{m=x.getBoundingClientRect(),C(Math.floor(f.clientHeight/h)-D.n)}).observe(f),x.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&w.ins(e.split("\n"))}),u.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",w._.join("\n"))}),u.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",w._.join("\n")),w.del(),x.focus({preventScroll:!0})});const $={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};x.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,i=t.shiftKey,n={v:()=>{},c:()=>{u.focus({preventScroll:!0}),u.select()},x:()=>{u.focus({preventScroll:!0}),u.select()},z:()=>{t.preventDefault(),this.History&&this.History[i?"redo":"undo"]()}},l={Backspace:()=>{w.del()},Enter:()=>{w.ins(["",""])},Tab:()=>{t.preventDefault(),w.dir||i?w.dent(i?-b.s:b.s):w.ins([" ".repeat(b.s)])}},r=$[s]||0;if(r){if(t.preventDefault(),b.i<0)return;const s=r>>31|1;if(e||t.altKey)!i&&w.dir?w.cursor():i&&!w.dir&&w.select(),r%2&&(e?w.mvLn(s>0):w.mvW(s));else if(!i&&w.dir)if(r%2)w.cursor(w.bounds(1)[s>0|0]),L();else{const t=w.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,M.last));c=Math.min(t.x,M._[e].length),w.cursor({y:e,x:c}),eD.last?D.set(e-D.n+1):L()}else i&&!w.dir&&w.select(),w[r%2?"mvX":"mvY"](s)}else 1===s.length?e?n[s.toLowerCase()]?.():b.i>0&&(" "===s&&t.preventDefault(),w.ins([s])):l[s]&&b.i>=1&&l[s]()})} \ No newline at end of file +function Buffee(t,{h:e,w:s,s:n=4}={}){this.v="15.6.0-alpha.1",this.$=t;const i={y:0,x:0};let l={y:0,x:0},r=l,c=l.x;const[h,o,a,y]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[f,x,d,u,p,g,_]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let m=x.getBoundingClientRect();const v=[[g,(t,e)=>t.textContent=M._[D.first+e]??null],[p,(t,e)=>t.textContent=D.first+e+1],[_,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),w=this.Span={bounds:t=>t&&w.dir>0?[r,l]:[l,r],mvY(t,e){if(t>0?l.y0){const s=M._[t>0?++l.y:--l.y].length;l.x=e?t>0?0:s:Math.min(c,s),e&&(c=l.x),l.yD.last?D.set(t>0?l.y-D.n+1:l.y):L()}},mvX(t){const e=t>0;(e?l.x0&&s0;if(l.x!==(r?i:0)){let t=l.x;const c=r?()=>tt>0,h=r?()=>t++:()=>t--;if(e.test(n[t])){for(;c()&&e.test(n[t]);)h();for(;c()&&s.test(n[t]);)h()}else if(s.test(n[t]))for(;c()&&s.test(n[t]);)h();else{const e=n[t];for(h();c()&&n[t]===e;)h()}l.x=t,L()}else(r?l.y0)&&(l.x=r?0:M._[--l.y].length,r&&++l.y>D.last?D.set(l.y-D.n+1):!r&&l.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(l.y=t.y,l.x=t.x),r.y=l.y,r.x=l.x,l=r},select(){l=i,l.y=r.y,l.x=r.x},ins(t){if(this.dir){const[e,s]=w.bounds(1);M.del(e.y,e.x,s.y,s.x+(this.dir>0)),M.ins(e.y,e.x,t),l.y=e.y,t.length>1?(l.y+=t.length-1,l.x=t[t.length-1].length):l.x=e.x+(t[0]?.length||0),this.cursor()}else M.ins(l.y,l.x,t),t.length>1?(l.y+=t.length-1,c=l.x=t[t.length-1].length):c=l.x+=t[0]?.length||0;l.y>D.last?D.set(l.y-D.n+1):L()},del(){this.dir?this.ins([""]):l.x>0?(M.del(l.y,l.x-1,l.y,l.x),l.x--,L()):l.y>0&&(l.x=M._[l.y-1].length,M.del(l.y-1,l.x,l.y,0),--l.y0&&!this.dir)return;const[e,s]=w.bounds(1);for(let n=e.y;n<=s.y;n++){const i=M._[n];if(t>0)M._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);M._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),L()}},b=this.Mode={s:n,i:1,f:0,ch:h,cw:d.getBoundingClientRect().width,sub:[],ext:[]},M=this.Model={_:[""],get last(){return this._.length-1},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},D=this.View={first:0,n:0,get N(){return this.n+!e},get last(){return Math.min(this.first+this.n-1,M.last)},set(t,e=this.n){const s=e-this.n;this.n=e,this.first=Math.max(0,Math.min(t,M.last)),E(s)},get _(){return M._.slice(this.first,this.last+1)}},E=D.RENDER=t=>{if(t){let e=t;for(;e>0;e--)v.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&v.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)v.forEach(([t])=>t.pop()?.remove())}if(p){const t=Math.max(a,(D.first+D.N).toString().length)+y;p.style.width=t+"ch",s&&(f.style.width=`calc(${t+s}ch + ${4*o}px)`)}L(t)},L=D.render=(t=0)=>{b.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(b.i>=0){const[t,s]=w.bounds(1),n=Math.min(D.first+D.n,s.y+1);for(let e=Math.max(D.first,t.y);e=0&&is?r-s:0))/b.cw)*b.cw}}d.style.left=e+"ch",b.sub.forEach(e=>e(x,D,t))};s&&!p&&(f.style.width=`calc(${s}ch + ${2*o}px)`),e&&v.forEach(([,,t])=>t&&(t.style.height=e*h+"px"));const C=t=>{D.n+=t,E(t)};e?C(e):new ResizeObserver(()=>{m=x.getBoundingClientRect(),C(Math.floor(f.clientHeight/h)-D.n)}).observe(f),x.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&w.ins(e.split("\n"))}),u.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",w._.join("\n"))}),u.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",w._.join("\n")),w.del(),x.focus({preventScroll:!0})});const $={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};x.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{u.focus({preventScroll:!0}),u.select()},x:()=>{u.focus({preventScroll:!0}),u.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{w.del()},Enter:()=>{w.ins(["",""])},Tab:()=>{t.preventDefault(),w.dir||n?w.dent(n?-b.s:b.s):w.ins([" ".repeat(b.s)])}},r=$[s]||0;if(r){if(t.preventDefault(),b.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&w.dir?w.cursor():n&&!w.dir&&w.select(),r%2&&(e?w.mvLn(s>0):w.mvW(s));else if(!n&&w.dir)if(r%2)w.cursor(w.bounds(1)[s>0|0]),L();else{const t=w.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,M.last));c=Math.min(t.x,M._[e].length),w.cursor({y:e,x:c}),eD.last?D.set(e-D.n+1):L()}else n&&!w.dir&&w.select(),w[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():b.i>0&&(" "===s&&t.preventDefault(),w.ins([s])):l[s]&&b.i>=1&&l[s]()})} \ No newline at end of file diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 21e8a57a..00000000 --- a/docs/api.md +++ /dev/null @@ -1,95 +0,0 @@ -# Buffee API Reference - -## Instantiation - -```javascript -const editor = new Buffee(element, { h, w, s }) -``` - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `h` | number | auto | Fixed visible lines | -| `w` | number | auto | Fixed text columns | -| `s` | number | 4 | Tab width (0 = hard tabs) | - -## Top-level properties - -```javascript -editor - .v // Version string - .$ // Root DOM element - .render(delta) // Render content only (delta = viewport size change) - .RENDER(delta) // Rebuild containers and render content - .Model // see Model namespace below - .View // see View namespace below - .Span // see Span namespace below - .Mode // see Mode namespace below -``` - -## Model (`editor.Model`) - -```javascript -Model - ._ // text buffer. Assumes user sanitized '\n', '\t', zero-width, multi-width chars - .ins // primitive insert - .del // primitive del - - // Convenience utilities - .last // index of last line of Model -``` - -When updating buffer, recall render if necessary. Suppose you append to ._ and the new lines -are out of view, then a render would not be necessary. While we could have added a setter -for the model that would know to call render, this would meant that the Model has to be concerned -with the view. The philosophy is that Model should be agnostic to existence of rendering. - -## View (`editor.View`) - -```javascript -View - .first // Model index of first line of viewport - .n // Viewport size - number of lines (settable) - .set(first) // Scroll to line, keep current size - .set(first, n) // Scroll to line with new size - ._ // Visible lines array (derived: Model._.slice(first, last + 1)) - - // Convenience utilities - .last // Model index of last line viewport - .N // Number of DOM containers (n + 1 if auto-fit) -``` - -The paradigm is to define first and size, but last is derived. An alternative implementation -was first and last, but size is derived. The latter's API appears symmetrical but it was not -as intuitive and the implementation uglier. - -## Span (`editor.Span`) - -A continuous text span from a starting and end coordinate. - -```javascript -Span - .bounds(value) // returns bounds of the span : truthy: [start, end], falsey [head, tail] - .dir // orientation of the selection: 1 (forward), -1 (backward), 0 (cursor) - .mvX(value) // move horizontally : 1 right , -1 left - .mvY(value) // move vertically : 1 down , -1 up - .mvLn(value) // move to '\n' : 1 end , 0 front - .mvW(value) // move by words : 1 next , -1 prev - .del() // delete text Span-wise delete - .ins(lines) // insert lines (string[]) Span-wise insert - .select() // make selection - .cursor() // make cursor - .dent(value) // indent or unindent : 1 indent, -1 unident -``` - -## Mode (`editor.Mode`) - -```javascript -Mode - .s // Tab width - .i // Edit mode: 1=write, 0=navigate, -1=read - .f // Render frame counter - .ch // Line height in pixels - .cw // Character width in pixels - .sub // subscriptions for render callback - .ext // Array of registered extension names (in order) -``` diff --git a/docs/api.txt b/docs/api.txt new file mode 100644 index 00000000..2848e6dd --- /dev/null +++ b/docs/api.txt @@ -0,0 +1,86 @@ +Buffee API Reference +==================== + +Instantiation +------------- +const instance = new Buffee(element, { h, w, s }) + + Option Type Default Description + ------ ------ ------- ---------------------- + h number auto Fixed visible lines + w number auto Fixed text columns + s number 4 Tab width (0 = hard tabs) + + +Top-level properties +-------------------- +instance + .v Version string + .$ Root DOM element + .Model see Model namespace below + .View see View namespace below + .Span see Span namespace below + .Mode see Mode namespace below + + +instance.Model +-------------------- +Model + ._ text buffer. Assumes sanitized '\n', '\t', zero/multi-width chars + .ins primitive insert + .del primitive del + .last index of last line of Model + +When updating buffer, call render if necessary. If you append to ._ and the new +lines are out of view, then a render would not be necessary. While we could have +added a setter for the model that would know to call render, this would mean that +the Model has to be concerned with the view. The philosophy is that Model should +be agnostic to existence of rendering. + + +instance.View +------------------ +View + .first Model index of first line of viewport + .n Viewport size - number of lines (settable) + .set(first) Scroll to line, keep current size + .set(first, n) Scroll to line with new size + ._ Visible lines array (derived: Model._.slice(first, last + 1)) + .render(delta) Render content only (delta = viewport size change) + .RENDER(delta) Rebuild containers and render content + .last Model index of last line viewport + .N Number of DOM containers (n + 1 if auto-fit) + +The paradigm is to define first and size, but last is derived. An alternative +implementation was first and last, but size is derived. The latter's API appears +symmetrical but it was not as intuitive and the implementation uglier. + + +instance.Span +------------------ +A continuous text span from a starting and end coordinate. + +Span + .bounds(value) returns bounds: truthy [start, end], falsey [head, tail] + .dir orientation: 1 (forward), -1 (backward), 0 (cursor) + .mvX(value) move horizontally : 1 right , -1 left + .mvY(value) move vertically : 1 down , -1 up + .mvLn(value) move to '\n' : 1 end , 0 front + .mvW(value) move by words : 1 next , -1 prev + .del() delete text Span-wise + .ins(lines) insert lines (string[]) Span-wise + .select() make selection + .cursor() make cursor + .dent(value) indent or unindent : 1 indent, -1 unindent + + +instance.Mode +------------------ +Mode (editor.Mode) + .s Tab width + .i Edit mode: 1=write, 0=navigate, -1=read + .f Render frame counter + .ch Line height in pixels + .cw Character width in pixels + .sub subscriptions for render callback + .ext Array of registered extension names (in order) diff --git a/docs/onboarding.md b/docs/onboarding.md index b49bf3df..87623ff9 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -53,7 +53,7 @@ BuffeeStatusLine(editor); ```javascript editor.Model._ = ["Hello, World!"]; // Array of lines -editor.render(); // Trigger re-render after setting content +editor.View.render(); // Trigger re-render after setting content ``` ## Sizing diff --git a/extensions/_template.js b/extensions/_template.js index ea61dd4f..1472eb53 100644 --- a/extensions/_template.js +++ b/extensions/_template.js @@ -17,9 +17,10 @@ function Buffee__NAME__(editor) { const { sub } = editor.Mode; // === EDITOR PROPERTIES === - // Available: Model, Select, View, Mode, render, $ + // Available: Model, Select, View, Mode, $ // Select.bounds() returns [head, tail], Select.bounds(1) returns ordered - const { Model, View, Mode, render, $ } = editor; + const { Model, View, Mode, $ } = editor; + const { render } = View; const lineHeight = Mode.ch; const $e = $.querySelector('.buffee-pane'); diff --git a/extensions/elementals.js b/extensions/elementals.js index 781e1ed6..18795c61 100644 --- a/extensions/elementals.js +++ b/extensions/elementals.js @@ -15,7 +15,8 @@ */ function BuffeeElementals(editor) { const { sub, ch: lineHeight } = editor.Mode; - const { View, render, $ } = editor; + const { View, $ } = editor; + const { render } = View; const $elementLayer = $.querySelector('.buffee-layer-elements'); let enabled = false; diff --git a/extensions/fileloader.js b/extensions/fileloader.js index 500b50f4..a2f4f682 100644 --- a/extensions/fileloader.js +++ b/extensions/fileloader.js @@ -14,7 +14,8 @@ * await editor.FileLoader.streamMaterializedLoad(file); */ function BuffeeFileLoader(editor) { - const { Model, Mode, render, $ } = editor; + const { Model, Mode, View, $ } = editor; + const { render } = View; function appendLines(newLines, skipRender = false) { Model._.push(...newLines); diff --git a/extensions/history.js b/extensions/history.js index 8c5db1cb..c113c97a 100644 --- a/extensions/history.js +++ b/extensions/history.js @@ -16,7 +16,7 @@ function BuffeeHistory(editor) { const Model = editor.Model; const add = Model.ins.bind(Model); const del = Model.del.bind(Model); - const { render } = editor; + const { render } = editor.View; // State const undoStack = []; diff --git a/extensions/ios.js b/extensions/ios.js index 62673bf6..57af2720 100644 --- a/extensions/ios.js +++ b/extensions/ios.js @@ -100,7 +100,7 @@ function BuffeeIOS(editor) { // Bounds check col to line length const lineLength = Model._[absRow].length; Span.cursor({ y: absRow, x: Math.min(col, lineLength) }); - editor.render(); + editor.View.render(); } // Dispatch synthetic keydown to the editor diff --git a/extensions/treesitter.js b/extensions/treesitter.js index b7c8b6ef..59c9a746 100644 --- a/extensions/treesitter.js +++ b/extensions/treesitter.js @@ -18,7 +18,8 @@ */ function BuffeeTreeSitter(editor, { parser, query }) { const { sub } = editor.Mode; - const { View, Model, render, $ } = editor; + const { View, Model, $ } = editor; + const { render } = View; const $e = $.querySelector('.buffee-pane'); /** @type {boolean} */ diff --git a/extensions/tui.js b/extensions/tui.js index 1a21f8a2..f442f8fb 100644 --- a/extensions/tui.js +++ b/extensions/tui.js @@ -20,7 +20,8 @@ function BuffeeTUI(editor) { const Highlights = editor.Highlights; const { sub } = editor.Mode; - const { View, Model, render, $ } = editor; + const { View, Model, $ } = editor; + const { render } = View; const $textLayer = $.querySelector('.buffee-ztxt'); let enabled = false; diff --git a/extensions/ultrahighcapacity.js b/extensions/ultrahighcapacity.js index 743e1e69..e9f07adb 100644 --- a/extensions/ultrahighcapacity.js +++ b/extensions/ultrahighcapacity.js @@ -16,7 +16,8 @@ */ function BuffeeUltraHighCapacity(editor) { const { sub } = editor.Mode; - const { View, Model, Mode, render, $ } = editor; + const { View, Model, Mode, $ } = editor; + const { render } = View; const $e = $.querySelector('.buffee-pane'); // Store original methods/getters diff --git a/index.html b/index.html index 1d2e1776..21bb26a2 100644 --- a/index.html +++ b/index.html @@ -187,7 +187,7 @@

HackerNews Front Page

fetch("assets/instructions.txt") .then(response => response.text()) - .then(source => { primary.Model._ = source.split('\n'); primary.render(); }) + .then(source => { primary.Model._ = source.split('\n'); primary.View.render(); }) .catch(err => console.error("Error reading instructions.txt:", err)); // Fetch Hacker News as plaintext @@ -218,7 +218,7 @@

HackerNews Front Page

} fetchHackerNewsText() - .then(text => { hackernews.Model._ = text.split('\n'); hackernews.render(); }) + .then(text => { hackernews.Model._ = text.split('\n'); hackernews.View.render(); }) .catch(err => console.error("Error fetching Hacker News:", err)); // TUI Mode Demo diff --git a/samples/_template.html b/samples/_template.html index 3b66ccb3..ad0c5dfa 100644 --- a/samples/_template.html +++ b/samples/_template.html @@ -61,7 +61,7 @@

[Sample Name]

// Set content editor.Model._ = ['Hello, World!']; - editor.render(); + editor.View.render(); diff --git a/samples/sample-basic.html b/samples/sample-basic.html index 3edd5a4c..d562e40a 100644 --- a/samples/sample-basic.html +++ b/samples/sample-basic.html @@ -57,7 +57,7 @@

Basic Editor

'', 'hello();' ]; - editor.render(); + editor.View.render(); el.focus(); diff --git a/samples/sample-conway.html b/samples/sample-conway.html index ddf3490c..2db4e6ca 100644 --- a/samples/sample-conway.html +++ b/samples/sample-conway.html @@ -121,7 +121,7 @@

Conway's Game of Life

lines.push(line); } editor.Model._ = lines; - editor.render(); + editor.View.render(); } function playConway() { diff --git a/samples/sample-gutter-status.html b/samples/sample-gutter-status.html index ad875612..a7fc794a 100644 --- a/samples/sample-gutter-status.html +++ b/samples/sample-gutter-status.html @@ -177,13 +177,13 @@

6. Gutter on right

const elDefault = document.getElementById('editor-default'); const edDefault = BuffeeStatusLine(new Buffee(elDefault, { h: 6 })); edDefault.Model._ = contentLines.slice(); - edDefault.render(); + edDefault.View.render(); // No gutter const elNoGutter = document.getElementById('editor-no-gutter'); const edNoGutter = BuffeeStatusLine(new Buffee(elNoGutter, { h: 10 })); edNoGutter.Model._ = contentLines.slice(); - edNoGutter.render(); + edNoGutter.View.render(); // No status bar const elNoStatus = document.getElementById('editor-no-status'); @@ -191,25 +191,25 @@

6. Gutter on right

h: 10 }); edNoStatus.Model._ = contentLines.slice(); - edNoStatus.render(); + edNoStatus.View.render(); // Status line on top const elStatusTop = document.getElementById('editor-status-top'); const edStatusTop = BuffeeStatusLine(new Buffee(elStatusTop, { h: 10 })); edStatusTop.Model._ = contentLines.slice(); - edStatusTop.render(); + edStatusTop.View.render(); // Selection range display const elSelection = document.getElementById('editor-selection'); const edSelection = BuffeeStatusLine(new Buffee(elSelection, { h: 6 }), { showSelection: true }); edSelection.Model._ = contentLines.slice(); - edSelection.render(); + edSelection.View.render(); // Gutter on right const elGutterRight = document.getElementById('editor-gutter-right'); const edGutterRight = BuffeeStatusLine(new Buffee(elGutterRight, { h: 10 })); edGutterRight.Model._ = contentLines.slice(); - edGutterRight.render(); + edGutterRight.View.render(); diff --git a/samples/sample-history.html b/samples/sample-history.html index 2d1b5ee9..8fabc60e 100644 --- a/samples/sample-history.html +++ b/samples/sample-history.html @@ -74,14 +74,14 @@

With History

const el1 = document.getElementById('editor-no-history'); const editorNoHistory = BuffeeStatusLine(new Buffee(el1, { h: 10 })); editorNoHistory.Model._ = ['// No history - Cmd+Z won\'t work', '// Try typing and undoing...']; - editorNoHistory.render(); + editorNoHistory.View.render(); // Editor WITH history - full undo/redo support const el2 = document.getElementById('editor-with-history'); const editorWithHistory = BuffeeStatusLine(new Buffee(el2, { h: 10 })); BuffeeHistory(editorWithHistory); // Enable history extension editorWithHistory.Model._ = ['// With history - Cmd+Z works!', '// Try typing and undoing...']; - editorWithHistory.render(); + editorWithHistory.View.render(); diff --git a/samples/sample-ios.html b/samples/sample-ios.html index 66f46f2f..7defbf37 100644 --- a/samples/sample-ios.html +++ b/samples/sample-ios.html @@ -65,7 +65,7 @@

JavaScript

'', 'console.log(greet("iOS"));' ]; - editor.render(); + editor.View.render(); el.focus(); diff --git a/samples/sample-loader.html b/samples/sample-loader.html index b441d8b1..4cd0b4f8 100644 --- a/samples/sample-loader.html +++ b/samples/sample-loader.html @@ -158,7 +158,7 @@

Logs:

)))); editor.Model._ = ['Select a file to load...']; - editor.render(); + editor.View.render(); editorEl.focus(); // View controls diff --git a/samples/sample-matrix.html b/samples/sample-matrix.html index 7d117533..bb7e08d4 100644 --- a/samples/sample-matrix.html +++ b/samples/sample-matrix.html @@ -89,7 +89,7 @@

Matrix Digital Rain

} editor.Model._ = lines; - editor.render(); + editor.View.render(); } // Start Matrix animation diff --git a/samples/sample-movie.html b/samples/sample-movie.html index bd11db31..2e526d88 100644 --- a/samples/sample-movie.html +++ b/samples/sample-movie.html @@ -55,7 +55,7 @@

ASCII Movie

const editor = BuffeeStatusLine(new Buffee(editorEl, { h: 13 })); editor.Model._ = ['Loading Star Wars ASCII movie...']; - editor.render(); + editor.View.render(); let movieData = null; let frames = []; @@ -89,14 +89,14 @@

ASCII Movie

} catch (err) { console.error("Error loading Star Wars movie:", err); editor.Model._ = ["Error loading movie. Check that sw.txt is in the assets directory."]; - editor.render(); + editor.View.render(); } } function showFrame(frameIndex) { if (frames.length > 0) { editor.Model._ = frames[frameIndex % frames.length].split('\n'); - editor.render(); + editor.View.render(); document.getElementById('frame-counter').textContent = `${frameIndex + 1} / ${frames.length}`; document.getElementById('frame-slider').value = frameIndex; } diff --git a/samples/sample-readonly.html b/samples/sample-readonly.html index 3e22bc88..4a699f30 100644 --- a/samples/sample-readonly.html +++ b/samples/sample-readonly.html @@ -85,7 +85,7 @@

2: Ultra High Capacity Mode

const edTui = BuffeeStatusLine(new Buffee(elTui, { h: 10 })); BuffeeTUI(edTui); edTui.Model._ = content.split('\n'); - edTui.render(); + edTui.View.render(); edTui.TUI.enabled = true; // Option 2: Ultra High Capacity mode (for large files) diff --git a/samples/sample-sizing.html b/samples/sample-sizing.html index 212b288e..f24fb170 100644 --- a/samples/sample-sizing.html +++ b/samples/sample-sizing.html @@ -198,56 +198,56 @@

8. Auto-h, fixed-w (contain: inline-size)

const edAutoBoth = BuffeeStatusLine(new Buffee(elAutoBoth)); edAutoBoth.Model._ = [ruler(120), '// Auto-h and auto-w', '// Drag corner to resize'].concat( Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); - edAutoBoth.render(); + edAutoBoth.View.render(); // 2. Fixed-h, auto-w const elFixedRowsAutoCols = document.getElementById('editor-fixed-rows-auto-cols'); const edFixedRowsAutoCols = BuffeeStatusLine(new Buffee(elFixedRowsAutoCols, { h: 8 })); edFixedRowsAutoCols.Model._ = [ruler(120), '// Fixed 8 rows, auto-w', 'Line 3'].concat( Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); - edFixedRowsAutoCols.render(); + edFixedRowsAutoCols.View.render(); // 3. h smaller than container const elFixedRowsLarge = document.getElementById('editor-fixed-rows-large-container'); const edFixedRowsLarge = BuffeeStatusLine(new Buffee(elFixedRowsLarge, { h: 5 })); edFixedRowsLarge.Model._ = [ruler(80), '// h: 5 overrides container height'].concat( Array.from({length: 10}, (_, i) => `Line ${i + 3}`)); - edFixedRowsLarge.render(); + edFixedRowsLarge.View.render(); // 4. h larger than container const elFixedRowsOverflow = document.getElementById('editor-fixed-rows-overflow'); const edFixedRowsOverflow = BuffeeStatusLine(new Buffee(elFixedRowsOverflow, { h: 20 })); edFixedRowsOverflow.Model._ = [ruler(80), '// h: 20 exceeds container height'].concat( Array.from({length: 25}, (_, i) => `Line ${i + 3}`)); - edFixedRowsOverflow.render(); + edFixedRowsOverflow.View.render(); // 5. h larger than container (scrollable) const elFixedRowsScroll = document.getElementById('editor-fixed-rows-scroll'); const edFixedRowsScroll = BuffeeStatusLine(new Buffee(elFixedRowsScroll, { h: 20 })); edFixedRowsScroll.Model._ = [ruler(80), '// h: 20 exceeds container height', '// Scroll to see more'].concat( Array.from({length: 25}, (_, i) => `Line ${i + 4}`)); - edFixedRowsScroll.render(); + edFixedRowsScroll.View.render(); // 6. Auto-h, fixed-w (status bar stretches) const elFixedCols = document.getElementById('editor-fixed-cols'); const edFixedCols = BuffeeStatusLine(new Buffee(elFixedCols, { w: 40 })); edFixedCols.Model._ = [ruler(40), '// Fixed 55 cols', '// Status bar stretches'].concat( Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); - edFixedCols.render(); + edFixedCols.View.render(); // 7. Auto-h, fixed-w (fit-content) const elFitContent = document.getElementById('editor-fit-content'); const edFitContent = BuffeeStatusLine(new Buffee(elFitContent, { w: 40 })); edFitContent.Model._ = [ruler(55), '// Fixed 55 cols', '// width: fit-content'].concat( Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); - edFitContent.render(); + edFitContent.View.render(); // 8. Auto-h, fixed-w (contain: inline-size) const elContain = document.getElementById('editor-contain'); const edContain = BuffeeStatusLine(new Buffee(elContain, { w: 20 })); edContain.Model._ = [ruler(20), '// Fixed 20 cols', '// contain: inline-size'].concat( Array.from({length: 20}, (_, i) => `Line ${i + 4}`)); - edContain.render(); + edContain.View.render(); diff --git a/samples/sample-syntax.html b/samples/sample-syntax.html index b9932b87..6876dfd4 100644 --- a/samples/sample-syntax.html +++ b/samples/sample-syntax.html @@ -330,7 +330,7 @@

Hello World

// Load initial sample editor.Model._ = samples.javascript.split('\n'); - editor.render(); + editor.View.render(); // Language switcher langSelect.addEventListener('change', () => { @@ -338,7 +338,7 @@

Hello World

editor.Syntax.setLanguage(lang); editor.Syntax.clearCache(); editor.Model._ = samples[lang].split('\n'); - editor.render(); + editor.View.render(); updateStateDisplay(); }); @@ -346,7 +346,7 @@

Hello World

const syntaxCheckbox = document.getElementById('syntax-checkbox'); syntaxCheckbox.addEventListener('change', () => { editor.Syntax.enabled = syntaxCheckbox.checked; - editor.render(); + editor.View.render(); }); // File loader diff --git a/samples/sample-undotree.html b/samples/sample-undotree.html index 8b8f9f9a..920a7446 100644 --- a/samples/sample-undotree.html +++ b/samples/sample-undotree.html @@ -267,7 +267,7 @@

History Tree

btnClear.onclick = () => { editor.UndoTree.clear(); editor.Model._ = ['']; - editor.render(); + editor.View.render(); updateTree(); }; diff --git a/test/lib/test-extensions.js b/test/lib/test-extensions.js index 3d294cc0..5ebd8e4e 100644 --- a/test/lib/test-extensions.js +++ b/test/lib/test-extensions.js @@ -132,7 +132,7 @@ function defineExtensionTests() { editor.Syntax.setLanguage('javascript'); editor.Syntax.enabled = true; editor.Model._ = ['const x = 42;']; - editor.render(); + editor.View.render(); const { tokens } = editor.Syntax.tokenizeLine('const x = 42;', 0); assertTrue(tokens.length > 0, 'Should have tokens'); @@ -162,7 +162,7 @@ function defineExtensionTests() { BuffeeSyntax(editor); editor.Syntax.setLanguage('javascript'); editor.Model._ = ['/* start', 'middle', 'end */']; - editor.render(); + editor.View.render(); // First line starts comment const result1 = editor.Syntax.tokenizeLine('/* start', 0); @@ -187,7 +187,7 @@ function defineExtensionTests() { editor.Syntax.setLanguage('javascript'); editor.Syntax.enabled = true; editor.Model._ = ['line1', 'line2', 'line3']; - editor.render(); + editor.View.render(); // Force state cache population editor.Syntax.ensureStateCache(2); @@ -234,7 +234,7 @@ function defineExtensionTests() { editor.Syntax.enabled = true; // Create 10 lines to fill viewport editor.Model._ = ['const a = 1;', 'const b = 2;', 'const c = 3;', 'const d = 4;', 'const e = 5;', 'const f = 6;', 'const g = 7;', 'const h = 8;', 'const i = 9;', 'const j = 10;']; - editor.render(); + editor.View.render(); // Check that highlighting was applied to lines in viewport const $textLayer = editor.$.querySelector('.buffee-ztxt'); @@ -256,7 +256,7 @@ function defineExtensionTests() { // Set initial multiline content editor.Model._ = ['/* comment', 'still comment', 'end */']; - editor.render(); + editor.View.render(); editor.Syntax.ensureStateCache(3); assertTrue(editor.Syntax.stateCache.length >= 3, 'Cache should be populated'); @@ -279,7 +279,7 @@ function defineExtensionTests() { editor.Model._ = ['', '', '', '', '', '']; editor.Elementals.addButton({ row: 2, col: 5, label: 'Test' }); editor.Elementals.enabled = true; - editor.render(); + editor.View.render(); // Element should be visible (not display:none) and positioned const el = editor.Elementals.elements[0]; @@ -460,7 +460,7 @@ function defineExtensionTests() { editor.Model._ = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; editor.TUI.addButton({ row: 5, col: 0, label: 'Mid' }); editor.TUI.enabled = true; - editor.render(); + editor.View.render(); // Element at row 5 should be in viewport (0-9 visible) // If viewport.size was used instead of viewport.n, this would fail @@ -1093,7 +1093,7 @@ function defineExtensionTests() { // Set text with 5 lines editor.Model._ = ['line1', 'line2', 'line3', 'line4', 'line5']; - editor.render(); + editor.View.render(); // Should show correct line count assertTrue($lineCounter.textContent.includes('5'), diff --git a/test/lib/test-ui.js b/test/lib/test-ui.js index e67f5036..f069b9f2 100644 --- a/test/lib/test-ui.js +++ b/test/lib/test-ui.js @@ -186,7 +186,7 @@ function setEditorContent(text) { if (dslEditor) { dslEditor.Model._ = text.split('\n'); - dslEditor.render(); + dslEditor.View.render(); } } @@ -263,7 +263,7 @@ if (jsOutputEditor) { jsOutputEditor.Syntax.clearCache(); jsOutputEditor.Model._ = jsOutput.split('\n'); - jsOutputEditor.render(); + jsOutputEditor.View.render(); } const outputEl = document.getElementById('js-output'); outputEl.dataset.plainJs = jsOutput; // Store plain JavaScript for eval @@ -350,7 +350,7 @@ lastCompileErrors = []; if (jsOutputEditor) { jsOutputEditor.Model._ = [`Error: ${error.message}`]; - jsOutputEditor.render(); + jsOutputEditor.View.render(); } delete outputEl.dataset.plainJs; outputEl.classList.remove('has-errors'); diff --git a/test/specs/spec-features.dsl b/test/specs/spec-features.dsl index 04c2a243..5559092f 100644 --- a/test/specs/spec-features.dsl +++ b/test/specs/spec-features.dsl @@ -39,7 +39,7 @@ expect(gutterWidthPx()).toBeCloseTo(initialWidth); ### Gutter based on viewport position, not total lines // Add 15 lines (more than viewport of 10) fixture.editor.Model._ = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]; -fixture.editor.render(); +fixture.editor.View.render(); const $gutter = fixture.node.querySelector(".buffee-rail"); // Using tolerance because computed style can differ slightly from actual const gutterWidthPx = () => parseFloat(getComputedStyle($gutter).width); @@ -57,7 +57,7 @@ expect(gutterWidthPx()).toBeCloseTo(initialWidth); ### Gutter grows from 2 to 3 digits when line 100 is visible // Create 100 lines fixture.editor.Model._ = Array(100).fill("x"); -fixture.editor.render(); +fixture.editor.View.render(); const $gutter = fixture.node.querySelector(".buffee-rail"); // Using tolerance because computed style can differ slightly from actual const gutterWidthPx = () => parseFloat(getComputedStyle($gutter).width); diff --git a/web/extensions-profile.html b/web/extensions-profile.html index 5672100e..3a954968 100644 --- a/web/extensions-profile.html +++ b/web/extensions-profile.html @@ -285,7 +285,7 @@

UltraHighCapacity

// Generate test content const testCode = generateCode(1000); editor.Model._ = testCode.split('\n'); - editor.render(); + editor.View.render(); const testLine = 'const greeting = "Hello, World!"; // This is a comment'; @@ -396,7 +396,7 @@

UltraHighCapacity

editor.TUI.enabled = true; start = performance.now(); for (let i = 0; i < iterations; i++) { - editor.render(); + editor.View.render(); } setMetric('tui-render', (performance.now() - start) / iterations, { fast: 1, medium: 5 }); diff --git a/web/extensions.html b/web/extensions.html index 4b1c3078..364082f0 100644 --- a/web/extensions.html +++ b/web/extensions.html @@ -117,7 +117,7 @@

Sanitize

// Use sanitization utilities when setting Model._ directly editor.Model._ = editor.Sanitize.lines(["a\tb", "c\td"]) -editor.render() +editor.View.render() // Manual sanitization utilities editor.Sanitize.line("hello\tworld") // "hello world" diff --git a/web/getting-started.html b/web/getting-started.html index 8d246a89..3e073660 100644 --- a/web/getting-started.html +++ b/web/getting-started.html @@ -67,7 +67,7 @@

4. Initialize

Instantiate Buffee which binds to the HTML element.
const editor = new Buffee(document.getElementById('editor'))
 editor.Model._ = ["Hello, World!"]
-editor._.render(true);
+editor.View.render();
 

5. Auto-fit View

By default (when h is omitted), the editor auto-fits to its container height. To disable auto-fit, specify h:

diff --git a/web/navigation.html b/web/navigation.html index e87e9a61..11721539 100644 --- a/web/navigation.html +++ b/web/navigation.html @@ -1,6 +1,6 @@ home | getting started | -api | +api | kitchen sink | extensions | themes | diff --git a/web/themes.html b/web/themes.html index a1d6a218..d07182f1 100644 --- a/web/themes.html +++ b/web/themes.html @@ -284,7 +284,7 @@

Boring

const el = document.getElementById('ed-' + name); const editor = BuffeeStatusLine(new Buffee(el)); editor.Model._ = sampleLines.slice(); - editor.render(); + editor.View.render(); }); diff --git a/web/wrappers.html b/web/wrappers.html index 55caa9dc..84a51e4d 100644 --- a/web/wrappers.html +++ b/web/wrappers.html @@ -26,7 +26,7 @@

React

useEffect(() => { if (editorRef.current) { editorRef.current.Model._ = ['Hello, World!']; - editorRef.current.render(); + editorRef.current.View.render(); } }, []); @@ -42,7 +42,7 @@

Svelte

function handleReady(e) { editor = e.detail; editor.Model._ = ['Hello, World!']; - editor.render(); + editor.View.render(); } </script> @@ -55,7 +55,7 @@

Vue

function handleReady(editor) { editor.Model._ = ['Hello, World!']; - editor.render(); + editor.View.render(); } </script> diff --git a/wrappers/react.jsx b/wrappers/react.jsx index 6f9cf348..53c30216 100644 --- a/wrappers/react.jsx +++ b/wrappers/react.jsx @@ -9,7 +9,7 @@ * useEffect(() => { * if (editorRef.current) { * editorRef.current.Model._ = ['Hello, World!']; - * editorRef.current.render(); + * editorRef.current.View.render(); * } * }, []); * @@ -69,7 +69,7 @@ const BuffeeEditor = forwardRef(function BuffeeEditor(props, ref) { if (initialText) { editor.Model._ = initialText.split('\n'); - editor.render(); + editor.View.render(); } if (onReady) { diff --git a/wrappers/svelte.svelte b/wrappers/svelte.svelte index ee227a87..e62f31c3 100644 --- a/wrappers/svelte.svelte +++ b/wrappers/svelte.svelte @@ -8,7 +8,7 @@ function handleReady(e) { editor = e.detail; editor.Model._ = ['Hello, World!']; - editor.render(); + editor.View.render(); } @@ -96,7 +96,7 @@ if (initialText) { editor.Model._ = initialText.split('\n'); - editor.render(); + editor.View.render(); } dispatch('ready', editor); diff --git a/wrappers/vue.js b/wrappers/vue.js index 58877ac5..1a3ea868 100644 --- a/wrappers/vue.js +++ b/wrappers/vue.js @@ -9,7 +9,7 @@ * * function handleReady(editor) { * editor.Model._ = ['Hello, World!']; - * editor.render(); + * editor.View.render(); * } * * @@ -76,7 +76,7 @@ const BuffeeEditor = defineComponent({ if (props.initialText) { editor.value.Model._ = props.initialText.split('\n'); - editor.value.render(); + editor.value.View.render(); } emit('ready', editor.value); From ef9ff9ba8a573b0e148378fafa0a4f0a03df59ee Mon Sep 17 00:00:00 2001 From: varrockbank Date: Tue, 13 Jan 2026 08:19:15 +0100 Subject: [PATCH 09/50] web: add Sanitize extension example, rename Kitchen Sink to Examples --- CONTRIBUTING.md | 2 +- samples/index.html | 7 +-- samples/sample-sanitize.html | 86 ++++++++++++++++++++++++++++++++++++ web/extensions.html | 1 + web/navigation.html | 2 +- 5 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 samples/sample-sanitize.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43384fbe..c84635ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ web - github pages related stuff test - test related stuff assets - for github pages assets -samples - kitchen sink samples +samples - examples theme - css themess wrappers - Component wrappers in JavaScript UI frameworks resources - test files, mostly for load testing diff --git a/samples/index.html b/samples/index.html index 39a81969..acdd3eec 100644 --- a/samples/index.html +++ b/samples/index.html @@ -2,7 +2,7 @@ - buffee - Kitchen Sink + buffee - Examples @@ -13,9 +13,9 @@ .then(r => r.text()) .then(html => document.getElementById('nav').innerHTML = html); -

home / Kitchen Sink

+

home / Examples

-

Kitchen Sink

+

Examples

Core

Fun Examples

diff --git a/samples/sample-sanitize.html b/samples/sample-sanitize.html new file mode 100644 index 00000000..0f768d00 --- /dev/null +++ b/samples/sample-sanitize.html @@ -0,0 +1,86 @@ + + + + + buffee - Sanitize Extension + + + + + + + + + + +

home / samples / Sanitize Extension

+ +

Sanitize Extension

+

Automatically converts tabs to spaces and normalizes problematic Unicode characters.

+

BuffeeSanitize(new Buffee(el, {}))

+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ Ln , Col + | + +
+
+
+ +

Demo

+

The content below was loaded with tabs and Unicode spaces. Sanitize converted them:

+
    +
  • Tabs (\t) become 4 spaces
  • +
  • Em spaces, en spaces become regular spaces
  • +
  • Zero-width characters are removed
  • +
+

Try pasting text with tabs - they'll be converted automatically.

+ +

Manual Sanitization

+

When setting Model._ directly, use the Sanitize utilities:

+
editor.Model._ = editor.Sanitize.lines(["a\tb", "c\td"]);
+editor.View.render();
+ + + + diff --git a/web/extensions.html b/web/extensions.html index 364082f0..95fbf952 100644 --- a/web/extensions.html +++ b/web/extensions.html @@ -124,6 +124,7 @@

Sanitize

editor.Sanitize.text("a\tb\nc\td") // sanitize multi-line string editor.Sanitize.lines(["a\tb", "c\td"]) // sanitize array of lines

Why opt-in? Buffee's core is minimal. Automatic sanitization adds overhead and may not suit all use cases. Some applications may want to handle tabs differently, preserve certain Unicode, or sanitize upstream.

+

Demo →


diff --git a/web/navigation.html b/web/navigation.html index 11721539..efc860b6 100644 --- a/web/navigation.html +++ b/web/navigation.html @@ -1,7 +1,7 @@ home | getting started | api | -kitchen sink | +examples | extensions | themes | wrappers | From a4dc8f44396c38e5c06fe3be672aa079dee279ac Mon Sep 17 00:00:00 2001 From: varrockbank Date: Tue, 13 Jan 2026 08:21:19 +0100 Subject: [PATCH 10/50] web: update wrappers to use h/w/s props, add API Changes section to CLAUDE.md --- CLAUDE.md | 10 ++++++++++ web/wrappers.html | 12 ++++++------ wrappers/react.jsx | 18 +++++++++--------- wrappers/svelte.svelte | 10 +++++----- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7fb40382..967b1c89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,16 @@ See `web/template.html` for the required HTML structure. Missing any element wil - `samples/*.html` (all sample files) - `web/themes.html` +## API Changes + +**When changing the Buffee API (config options, method names, namespaces), also update:** +- `wrappers/react.jsx` - React component props and config +- `wrappers/svelte.svelte` - Svelte component props and config +- `wrappers/vue.js` - Vue component props and config +- `docs/api.txt` - API reference +- `web/getting-started.html` - Usage examples +- `web/wrappers.html` - Wrapper documentation + ## Cursor Model (Vim-style) - **Cursor sits ON a character**, not between characters diff --git a/web/wrappers.html b/web/wrappers.html index 84a51e4d..71ac3cf1 100644 --- a/web/wrappers.html +++ b/web/wrappers.html @@ -30,7 +30,7 @@

React

} }, []); - return <BuffeeEditor ref={editorRef} rows={10} theme="eva" />; + return <BuffeeEditor ref={editorRef} h={10} theme="eva" />; }

Svelte

@@ -46,7 +46,7 @@

Svelte

} </script> -<BuffeeEditor rows={10} theme="eva" on:ready={handleReady} />
+<BuffeeEditor h={10} theme="eva" on:ready={handleReady} />

Vue

wrappers/vue.js

@@ -60,16 +60,16 @@

Vue

</script> <template> - <BuffeeEditor :rows="10" theme="eva" @ready="handleReady" /> + <BuffeeEditor :h="10" theme="eva" @ready="handleReady" /> </template>

Props

All wrappers support these props:

-
rows         Number   Fixed number of visible lines
-cols         Number   Fixed number of text columns
-spaces       Number   Spaces per tab (default: 4)
+  
h            Number   Fixed number of visible lines
+w            Number   Fixed number of text columns
+s            Number   Spaces per tab (default: 4)
 theme        String   Theme name (e.g., 'eva', 'nord', 'gruv')
 showGutter   Boolean  Show line numbers (default: true)
 gutterRight  Boolean  Position gutter on right side (default: false)
diff --git a/wrappers/react.jsx b/wrappers/react.jsx
index 53c30216..bc1025f7 100644
--- a/wrappers/react.jsx
+++ b/wrappers/react.jsx
@@ -13,7 +13,7 @@
  *     }
  *   }, []);
  *
- *   return ;
+ *   return ;
  * }
  */
 
@@ -22,9 +22,9 @@ import React, { useRef, useEffect, useImperativeHandle, forwardRef } from 'react
 /**
  * React component wrapper for Buffee editor.
  * @param {Object} props
- * @param {number} [props.rows] - Fixed number of visible lines
- * @param {number} [props.cols] - Fixed number of text columns
- * @param {number} [props.spaces=4] - Spaces per tab
+ * @param {number} [props.h] - Fixed number of visible lines
+ * @param {number} [props.w] - Fixed number of text columns
+ * @param {number} [props.s=4] - Spaces per tab
  * @param {string} [props.theme] - Theme name (e.g., 'eva', 'nord', 'gruv')
  * @param {string} [props.className] - Additional CSS classes
  * @param {boolean} [props.showGutter=true] - Show line numbers
@@ -34,9 +34,9 @@ import React, { useRef, useEffect, useImperativeHandle, forwardRef } from 'react
  */
 const BuffeeEditor = forwardRef(function BuffeeEditor(props, ref) {
   const {
-    rows,
-    cols,
-    spaces = 4,
+    h,
+    w,
+    s = 4,
     theme,
     className = '',
     showGutter = true,
@@ -57,7 +57,7 @@ const BuffeeEditor = forwardRef(function BuffeeEditor(props, ref) {
     if (!containerRef.current || typeof Buffee === 'undefined') return;
 
     const el = containerRef.current;
-    const config = { rows, cols, spaces };
+    const config = { h, w, s };
 
     let editor = new Buffee(el, config);
 
@@ -83,7 +83,7 @@ const BuffeeEditor = forwardRef(function BuffeeEditor(props, ref) {
       }
       editorRef.current = null;
     };
-  }, [rows, cols, spaces]);
+  }, [h, w, s]);
 
   const themeClass = theme ? `buffee-themepack1-${theme}` : '';
 
diff --git a/wrappers/svelte.svelte b/wrappers/svelte.svelte
index e62f31c3..a2466f5d 100644
--- a/wrappers/svelte.svelte
+++ b/wrappers/svelte.svelte
@@ -12,7 +12,7 @@
     }
   
 
-  
+  
 -->
 
 
-  
-  
-  
-  
-  
+  
+  
+  
+  
+  
   
   
   
@@ -56,7 +56,7 @@ 

[Sample Name]

const el = document.getElementById('editor'); const editor = BuffeeStatusLine(new Buffee(el, { h: 10 })); - // Initialize extensions + // Initialize combinators // BuffeeSyntax(editor); // Set content diff --git a/samples/index.html b/samples/index.html index acdd3eec..6fc6b494 100644 --- a/samples/index.html +++ b/samples/index.html @@ -24,9 +24,9 @@

Core

  • sample-sizing - Editor sizing with character units
  • -

    Extensions

    +

    Combinators

      -
    • sample-history - Undo/redo with history extension
    • +
    • sample-history - Undo/redo with history combinator
    • sample-undotree - Tree-based undo with branch navigation
    • sample-syntax - Regex-based syntax highlighting
    • sample-elementals - Layer-based UI elements (DOM nodes)
    • diff --git a/samples/sample-basic.html b/samples/sample-basic.html index d562e40a..edb6cb65 100644 --- a/samples/sample-basic.html +++ b/samples/sample-basic.html @@ -4,7 +4,7 @@ buffee - Basic Editor - + diff --git a/samples/sample-conway.html b/samples/sample-conway.html index 2db4e6ca..1bc6f3b7 100644 --- a/samples/sample-conway.html +++ b/samples/sample-conway.html @@ -4,7 +4,7 @@ buffee - Conway's Game of Life - + diff --git a/samples/sample-elementals.html b/samples/sample-elementals.html index 081b1825..442e0895 100644 --- a/samples/sample-elementals.html +++ b/samples/sample-elementals.html @@ -4,8 +4,8 @@ buffee - Elementals - - + + diff --git a/samples/sample-gutter-status.html b/samples/sample-gutter-status.html index a7fc794a..b5d0b73f 100644 --- a/samples/sample-gutter-status.html +++ b/samples/sample-gutter-status.html @@ -4,7 +4,7 @@ buffee - Gutter and Status Bar - + diff --git a/samples/sample-history.html b/samples/sample-history.html index 8fabc60e..aef71586 100644 --- a/samples/sample-history.html +++ b/samples/sample-history.html @@ -2,10 +2,10 @@ - buffee - History Extension + buffee - History Combinator - - + + @@ -18,9 +18,9 @@ .then(r => r.text()) .then(html => document.getElementById('nav').innerHTML = html); -

      home / samples / History Extension

      +

      home / samples / History Combinator

      -

      History Extension (Undo/Redo)

      +

      History Combinator (Undo/Redo)

      History tracking is opt-in. By default, editors have no undo/redo.

      Without History

      diff --git a/samples/sample-ios.html b/samples/sample-ios.html index 7defbf37..70a63ba6 100644 --- a/samples/sample-ios.html +++ b/samples/sample-ios.html @@ -5,8 +5,8 @@ buffee - iOS Support - - + + diff --git a/samples/sample-loader.html b/samples/sample-loader.html index 4cd0b4f8..7099898d 100644 --- a/samples/sample-loader.html +++ b/samples/sample-loader.html @@ -4,10 +4,10 @@ buffee - Ultra High Capacity - - - - + + + + diff --git a/samples/sample-matrix.html b/samples/sample-matrix.html index bb7e08d4..01e8ae3f 100644 --- a/samples/sample-matrix.html +++ b/samples/sample-matrix.html @@ -4,7 +4,7 @@ buffee - Matrix Digital Rain - + diff --git a/samples/sample-movie.html b/samples/sample-movie.html index 2e526d88..1cd34f59 100644 --- a/samples/sample-movie.html +++ b/samples/sample-movie.html @@ -4,7 +4,7 @@ buffee - ASCII Movie - + diff --git a/samples/sample-readonly.html b/samples/sample-readonly.html index 4a699f30..9388512e 100644 --- a/samples/sample-readonly.html +++ b/samples/sample-readonly.html @@ -4,10 +4,10 @@ buffee - Read-Only Mode - - - - + + + + diff --git a/samples/sample-sanitize.html b/samples/sample-sanitize.html index 0f768d00..3c4055b4 100644 --- a/samples/sample-sanitize.html +++ b/samples/sample-sanitize.html @@ -2,10 +2,10 @@ - buffee - Sanitize Extension + buffee - Sanitize Combinator - - + + @@ -17,9 +17,9 @@ .then(r => r.text()) .then(html => document.getElementById('nav').innerHTML = html); -

      home / samples / Sanitize Extension

      +

      home / samples / Sanitize Combinator

      -

      Sanitize Extension

      +

      Sanitize Combinator

      Automatically converts tabs to spaces and normalizes problematic Unicode characters.

      BuffeeSanitize(new Buffee(el, {}))

      diff --git a/samples/sample-sizing.html b/samples/sample-sizing.html index f24fb170..987f7b96 100644 --- a/samples/sample-sizing.html +++ b/samples/sample-sizing.html @@ -4,7 +4,7 @@ buffee - Editor Sizing - + diff --git a/samples/sample-syntax.html b/samples/sample-syntax.html index 6876dfd4..4e383bb8 100644 --- a/samples/sample-syntax.html +++ b/samples/sample-syntax.html @@ -4,8 +4,8 @@ buffee - Syntax Highlighting - - + + diff --git a/samples/sample-tui.html b/samples/sample-tui.html index 175b939d..9e80b063 100644 --- a/samples/sample-tui.html +++ b/samples/sample-tui.html @@ -4,9 +4,9 @@ buffee - TUI Elements - - - + + + diff --git a/samples/sample-undotree.html b/samples/sample-undotree.html index 920a7446..0368818b 100644 --- a/samples/sample-undotree.html +++ b/samples/sample-undotree.html @@ -2,10 +2,10 @@ - buffee - Undo Tree Extension + buffee - Undo Tree Combinator - - + + @@ -123,9 +123,9 @@ .then(r => r.text()) .then(html => document.getElementById('nav').innerHTML = html); -

      home / samples / Undo Tree Extension

      +

      home / samples / Undo Tree Combinator

      -

      Undo Tree Extension

      +

      Undo Tree Combinator

      Unlike linear history, undo tree preserves all branches. When you undo and make a new edit, you create a new branch instead of losing history.

      diff --git a/snapshot_testing/screenshots.spec.js b/snapshot_testing/screenshots.spec.js index 2d36ca69..00136551 100644 --- a/snapshot_testing/screenshots.spec.js +++ b/snapshot_testing/screenshots.spec.js @@ -5,7 +5,7 @@ const pages = [ // Root pages { name: 'index', path: '/' }, { name: 'getting-started', path: '/web/getting-started.html' }, - { name: 'extensions', path: '/web/extensions.html' }, + { name: 'combinators', path: '/web/combinators.html' }, { name: 'themes', path: '/web/themes.html' }, { name: 'comparison', path: '/web/comparison.html' }, @@ -26,7 +26,7 @@ const pages = [ // Test pages { name: 'test-index', path: '/test/' }, - { name: 'test-extensions', path: '/test/#extensions' }, + { name: 'test-combinators', path: '/test/#combinators' }, ]; // Selectors for dynamic content to mask during screenshots diff --git a/test/index.html b/test/index.html index 68ef4b28..cdfbd138 100644 --- a/test/index.html +++ b/test/index.html @@ -220,7 +220,7 @@

      This test setup was AI generated with adult supervision.

      - + @@ -280,7 +280,7 @@

      This test setup was AI generated with adult supervision.

      -
      +
      @@ -458,16 +458,16 @@

      This test setup was AI generated with adult supervision.

      - - - - - - - - - - + + + + + + + + + + @@ -477,7 +477,7 @@

      This test setup was AI generated with adult supervision.

      - + diff --git a/test/lib/test-extensions.js b/test/lib/test-combinators.js similarity index 100% rename from test/lib/test-extensions.js rename to test/lib/test-combinators.js diff --git a/web/extensions-profile.html b/web/combinators-profile.html similarity index 98% rename from web/extensions-profile.html rename to web/combinators-profile.html index 3a954968..cad6f7d4 100644 --- a/web/extensions-profile.html +++ b/web/combinators-profile.html @@ -5,11 +5,11 @@ Buffee - Extensions Profile - - - - - + + + + +