PageSourceSearch

https://retroapplejs.github.io/res/COM_MAIN.js

js retroapplejs.github.io collected 2026-10-03 09:50:23 UTC 133,322 bytes, 3,545 lines download raw bytes

1function COM()
2{
3  // v1.06 - PANE namespace + floating find navigation focus fix
4  /*
5  this.debugMe = function()
6  {
7    var o = apple2plus.DiskObj();
8    oCOM.Download('dump.nib',o.diskBytes[0])
9  }
10    */
11
12
13  const hexLookupTable = new Array(256);
14  for (let i = 0; i < 256; i++) {
15    hexLookupTable[i] = i.toString(16).padStart(2, '0');
16  }
17
18  const hex2tab=[
19    "00","01","02","03","04","05","06","07","08","09","0A","0B","0C","0D","0E","0F","10","11","12","13","14","15","16","17","18","19","1A","1B","1C","1D","1E","1F",
20    "20","21","22","23","24","25","26","27","28","29","2A","2B","2C","2D","2E","2F","30","31","32","33","34","35","36","37","38","39","3A","3B","3C","3D","3E","3F",
21    "40","41","42","43","44","45","46","47","48","49","4A","4B","4C","4D","4E","4F","50","51","52","53","54","55","56","57","58","59","5A","5B","5C","5D","5E","5F",
22    "60","61","62","63","64","65","66","67","68","69","6A","6B","6C","6D","6E","6F","70","71","72","73","74","75","76","77","78","79","7A","7B","7C","7D","7E","7F",
23    "80","81","82","83","84","85","86","87","88","89","8A","8B","8C","8D","8E","8F","90","91","92","93","94","95","96","97","98","99","9A","9B","9C","9D","9E","9F",
24    "A0","A1","A2","A3","A4","A5","A6","A7","A8","A9","AA","AB","AC","AD","AE","AF","B0","B1","B2","B3","B4","B5","B6","B7","B8","B9","BA","BB","BC","BD","BE","BF",
25    "C0","C1","C2","C3","C4","C5","C6","C7","C8","C9","CA","CB","CC","CD","CE","CF","D0","D1","D2","D3","D4","D5","D6","D7","D8","D9","DA","DB","DC","DD","DE","DF",
26    "E0","E1","E2","E3","E4","E5","E6","E7","E8","E9","EA","EB","EC","ED","EE","EF","F0","F1","F2","F3","F4","F5","F6","F7","F8","F9","FA","FB","FC","FD","FE","FF"];
27  
28  this.hextab= ['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F']; // TODO: remove from entire codebase
29  this.getHexByte    = function(v)   { return hex2tab[v&0xFF] }
30  this.getHexWord    = function(v)   { return hex2tab[v>>8] + hex2tab[v&0xFF] }
31  this.getHexMulti = function(v,m)
32  {
33      if(m === 2) return hex2tab[v & 0xFF];
34      if(m === 4) return hex2tab[(v >> 8) & 0xFF] + hex2tab[v & 0xFF];
35      return v.toString(16).padStart(m,"0").slice(-m).toUpperCase();
36  }
37  this.getBinMulti   = function(v,m) { return ("0".repeat(m)+v.toString(2)).slice(-m).toUpperCase() }
38
39  // Shared 6502 register/status formatter used by debugger views.
40  // Returns HTML because the status-register flag names are rendered as subscripts.
41  this.formatCpuStatusHTML = function(p)
42  {
43      p = Number(p) & 0xFF;
44      const names = ["n","v","-","b","d","i","z","c"];
45      let out = "SR=";
46      for(let bit=7; bit>=0; bit--)
47          out += "<sub>"+names[7-bit]+"</sub>"+((p>>bit)&1);
48      return out;
49  }
50
51  this.formatCpuRegistersHTML = function(state,options)
52  {
53      state = state || {};
54      options = options || {};
55      const fields = [];
56      const hexByte = this.getHexByte.bind(this);
57      const hexWord = this.getHexWord.bind(this);
58      const dollar = options.dollarPrefix ? "$" : "";
59
60      if(options.includePC && state.pc !== undefined)
61          fields.push("PC="+dollar+hexWord(Number(state.pc)&0xFFFF));
62      if(state.a !== undefined)  fields.push("A="+dollar+hexByte(Number(state.a)&0xFF));
63      if(state.x !== undefined)  fields.push("X="+dollar+hexByte(Number(state.x)&0xFF));
64      if(state.y !== undefined)  fields.push("Y="+dollar+hexByte(Number(state.y)&0xFF));
65      if(state.sp !== undefined) fields.push("SP="+dollar+hexByte(Number(state.sp)&0xFF));
66      if(state.p !== undefined)  fields.push(this.formatCpuStatusHTML(state.p));
67      return fields.join(" ");
68  }
69  //this.getNumByteArr = function(v)   { let y= Math.floor(v/2**32); return [y,(y<<8),(y<<16),(y<<24), v,(v<<8),(v<<16),(v<<24)].map(z=> z>>>24) } // convert JS number to byte array
70  this.getNumByteArr = function(v)   { let y= Math.floor(v/2**32); return [(v<<24),(v<<16),(v<<8),v,(y<<24),(y<<16),(y<<8),y].map(z=> z>>>24) } // convert JS number to byte array
71  this.getByteArrNum = function(arr) { return arr.reduce((a,c,i)=> a+c*2**(56-i*8),0) } // convert byte array to JS number
72
73  this.HEX2RGB       = function(hex)   { var n=parseInt(hex.slice(1),16); return [(n>>16)&0xFF,(n>>
738)&0xFF,n&0xFF] }
74  this.RGB2HEX       = function(color) { return [hex2tab[color[0]&0xFF],hex2tab[color[1]&0xFF],hex2tab[color[2]&0xFF]] }
75
76  this.RGB2IDX       = function(color,sig_bits)
77  {
78      const msk = (1<<sig_bits)-1, scl = (8-sig_bits);
79      return (((color[2]>>scl)&msk)<<sig_bits<<sig_bits) | (((color[1]>>scl)&msk)<<sig_bits) | ((color[0]>>scl)&msk)
80  }
81
82  this.IDX2RGB       = function(idx,sig_bits)
83  {
84      const msk = (1<<sig_bits)-1, scl = (8-sig_bits);
85      return [(idx & msk)<<scl,((idx>>sig_bits) & msk)<<scl,((idx>>sig_bits>>sig_bits) & msk)<<scl];
86  }
87
88  this.expandRngExpr = function(spec, modifiers = {})
89  {
90    return this.expandRng(this.parseRngExpr(spec, modifiers));
91  };
92
93  this.parseRngExpr = function(spec, modifiers = {})
94  {
95    const locate = modifiers._locate_ || ["<sub>", "</sub>"];
96    const vars = { ...modifiers };
97    delete vars._locate_;
98
99    const [rangePart, stepPart] = splitOutsideTags(spec, "/", locate);
100    const [fromPart, toPart]    = splitOutsideTags(rangePart, "-", locate);
101    const base = vars.base || 0;
102
103    var ret = {};
104    ret.from = parseRngToken(fromPart.trim(), vars, locate) - base;
105    if (toPart == null) return ret;       // single value expression
106    ret.to = parseRngToken(toPart.trim(), vars, locate) - base;
107    if(stepPart != null) ret.step = parseRngToken(stepPart.trim(), vars, locate);
108    return ret;
109  };
110                        
111  this.expandRng = function(a, b, c)
112  {
113    let from, to, step;
114
115    if (typeof a === "object" && a !== null) {
116      from = a.from;
117      to   = a.to;
118      step = (a.step === undefined) ? 1 : a.step;
119    } else {
120      from = a;
121      to   = b;
122      step = (c === undefined) ? 1 : c;
123    }
124
125    if (!Number.isInteger(from) || !Number.isInteger(to) || !Number.isInteger(step) || step === 0) {
126      throw new Error("expandRng expects integer from/to and a non-zero integer step");
127    }
128
129    if (from > to && step > 0) step = -step;
130    if (from < to && step < 0) step = -step;
131
132    const out = [];
133    if (step > 0) {
134      for (let n = from; n <= to; n += step) out.push(n);
135    } else {
136      for (let n = from; n >= to; n += step) out.push(n);
137    }
138    return out;
139  };
140
141  this.arrayRng = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
142
143  this.asHex = function(arr)
144  {
145    if (typeof arr === "number") arr = [arr];
146    return arr.map(function(n) {
147      return "0x" + n.toString(16).toUpperCase();
148    });
149  };
150
151  function parseRngToken(token, vars = {}, locate = ["<sub>", "</sub>"])
152  {
153    let s = String(token).replace(/\s+/g, "");
154    const isHex = s.startsWith("$");
155    if (isHex) s = s.slice(1);
156
157    const base = isHex ? 16 : 10;
158    const [openTag, closeTag] = locate;
159    const digitRe = isHex ? /[0-9A-Fa-f]/ : /[0-9]/;
160
161    let skeleton = "";
162    const terms = [];
163
164    for (let i = 0; i < s.length;) {
165      if (s.startsWith(openTag, i)) {
166        const end = s.indexOf(closeTag, i + openTag.length);
167        if (end < 0) throw new Error("Missing closing tag");
168
169        const expr = s.slice(i + openTag.length, end).trim();
170        if (!expr) throw new Error("Empty tagged expression");
171
172        terms.push({ expr: expr, pos: skeleton.length });
173        skeleton += "0";   // one digit slot placeholder
174        i = end + closeTag.length;
175        continue;
176      }
177
178      if (!digitRe.test(s[i])) {
179        throw new Error("Invalid digit '" + s[i] + "' in token: " + token);
180      }
181
182      skeleton += s[i];
183      i++;
184    }
185
186    if (!skeleton.length) throw new Error("Empty numeric token");
187
188    const baseValue = parseInt(skeleton, base);
189    let value = baseValue;
190
191    for (let i = 0; i < terms.length; i++) {
192      const digitsRight = skeleton.length - terms[i].pos - 1;
193      value += evalExpr(terms[i].expr, vars) * (base ** digitsRight);
194    }
195
196    return value;
197  }
198
199  function evalExpr(expr, vars)
200  {
201    const jsExpr = expr.replace(/\$/g, "0x");
202    const names = Object.keys(vars);
203    const vals  = names.map(function(k) { return vars[k]; });
204
205    const ret = Function(...names, `"use strict"; return (${jsExpr});`)(...vals);
206
207    if (!Number.isFinite(ret) || !Number.isInteger(ret))
208      throw new Error("Expression must evaluate to an integer: " + expr);
209
210    return ret;
211  }
212
213  function splitOutsideTags(str, sep, locate)
214  {
215    const [openTag, closeTag] = locate;
216    let depth = 0;
217
218    for (let i = 0; i < str.length; i++) {
219      if (str.startsWith(openTag, i)) {
220        depth++;
221        i += openTag.length - 1;
222        continue;
223      }
224      if (depth > 0 && str.startsWith(closeTag, i)) {
225        depth--;
226        i += closeTag.length - 1;
227        continue;
228      }
229      if (depth === 0 && str[i] === sep) {
230        return [str.slice(0, i), str.slice(i + 1)];
231      }
232    }
233
234    return [str, null];
235  }
236
237  this.base64ToArray = function(base64)
238  {
239      try{ var binary_string = window.atob(base64); } catch(e) { return null }
240      var len = binary_string.length;
241      var bytes = new Uint8Array(len);
242      for (var i = 0; i < len; i++) {
243          bytes[i] = binary_string.charCodeAt(i);
244      }
245      return bytes;
246  }
247
248  /*
249   * Shared embedded-data decoder.
250   *
251   * Offline catalogs use the same record shape for binary and text assets:
252   *   {encoding:"zlib-base64",data:"..."}
253   *   {encoding:"base64",data:"..."}
254   *
255   * A bare string remains a shorthand for zlib-base64.
256   * output is "bytes" (default) or "text".
257   */
258  this.decodeDataRecord = function(rec,output)
259  {
260      if(rec==null) return null;
261      if(typeof rec=="string")
262          rec = {encoding:"zlib-base64",data:rec};
263
264      var encoding = String(rec.encoding || "base64").toLowerCase();
265      var bytes = this.base64ToArray(String(rec.data || ""));
266
267      if(bytes==null)
268          throw new Error("Invalid base64 payload");
269
270      if(encoding=="zlib-base64")
271      {
272          if(typeof pako=="undefined" || typeof pako.inflate!="function")
273              throw new Error("zlib-base64 payload requires pako.inflate");
274          bytes = pako.inflate(bytes);
275      }
276      else if(encoding!="base64")
277          throw new Error("Unsupported data encoding: " + encoding);
278
279      if(output=="text")
280      {
281          if(typeof TextDecoder!="function")
282              throw new Error("TextDecoder is not available");
283          return new TextDecoder(rec.charset || "utf-8").decode(bytes);
284      }
285
286      return bytes;
287  }
288
289  this.ArrayBufferTobase64 = function(buffer) 
290  {
291      //return btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
292
293      var binary = '';
294      var bytes = new Uint8Array( buffer );
295      var len = buffer.byteLength;
296      for (var i = 0; i < len; i++) {
297          binary += String.fromCharCode( bytes[ i ] );
298      }
299      return window.btoa( binary );
300  }
301
302  this.b64EncodeUnicode = function(str)
303  {
304    // first we use encodeURIComponent to get percent-encoded Unicode,
305    // then we convert the percent encodings into raw bytes which
306    // can be fed into btoa.
307    return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
308        function toSolidBytes(match, p1) {
309            return String.fromCharCode('0x' + p1);
310    }))
311  }
312
313  this.b64DecodeUnicode = function(str)
314  {
315    // Going backwards: from bytestream, to percent-encoding, to original string.
316    return decodeURIComponent(atob(str).split('').map(function(c) {
317        return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
318    }).join(''))
319  }
320
321  this.Uint8ArrayFromBase64 = function(base64)
322  {
323    return Uint8Array.from(window.atob(base64), (v) => v.charCodeAt(0));
324  }
325
326  this.Uint8ArrayToBase64 = function(a)
327  {
328    // 1. Preprocess Uint8Array into String
329    // (TODO: fix RAM usage from intermediate array creation)
330    var a_s = Array.prototype.map.call(a, c => String.fromCharCode(c)).join(String());
331    // 2. Call btoa()
332    return btoa(a_s);
333  }
334
335  this.DumpBase64 = function(b64body,maxlen,lf)
336  {
337    var lf = lf===undefined ? "\n" : lf;
338    var maxlen = maxlen===undefined ? 1024 : maxlen;
339    var re = RegExp(".{1,"+maxlen+"}","g");
340    return "var b64 ="+lf+" \""+b64body.match(re).join('"'+lf+'+"')+"\";"+lf
341  }
342
343  this.DumpTxt = function(txt,maxlen,lf)
344  {
345    var lf = lf===undefined ? "\n" : lf;
346    var maxlen = maxlen===undefined ? 1024 : maxlen;
347    var re = RegExp(".{1,"+maxlen+"}","g");
348    return txt.match(re).join(lf);
349  }
350
351  this.base_convert = function (str, fromBase, toBase)
352  {
353    if(typeof(fromBase)=='object') { this.fromSymbols = fromBase[0] }
354    if(typeof(toBase)  =='object') { this.toSymbols = toBase[0] }
355    fromBase = this.fromSymbols.length; toBase = this.toSymbols.length;
356
357    // PARSE INPUT DIGITS ARRAY
358    for(var _a = [0], str = str.split(''); str.length > 0 && _a[_a.push(this.fromSymbols.indexOf(str.pop())) - 1] >= 0;);
359    var _d = _a.shift() + _a[_a.length-1]>=0 ? _a : null; if (_d === null) return null;
360
361    // BASE CONVERSION
362    for (var _n = 0,_a = [],_p = [1]; _n < _d.length; _n++) { _a = add(_a, mul(_d[_n], _p, toBase), toBase); _p = mul(fromBase, _p, toBase) }
363
364    // PARSE OUTPUT DIGITS ARRAY
365    for (var _n = _a.length - 1, _o = ''; _n >= 0; _o += this.toSymbols[_a[_n--]]);
366
367    function add(x, y, base) {
368        var _m = Math.max(x.length, y.length);
369        for(var _c = 0,_n = 0,_r = [],_z; _n < _m || _c; _c = Math.floor(_z / base)) {
370          var _z = _c + (_n < x.length ? x[_n] : 0) + (_n < y.length ? y[_n] : 0);
371          var _n =  _r.push(_z % base);
372        }
373        return _r;
374    }
375
376    function mul (x, pow, base) {
377        for(var _r = x < 0 ? null : []; x > 0; x = x >> 1) {
378          if(x & 1) _r = add(_r, pow, base);
379          pow = add(pow, pow, base);
380        }
381        return _r;
382    }
383
384    return _o.length==0?this.toSymbols[0]:_o;
385  }
386
387  this.symbols = 
388  {
389    32:function(){return this.base32hex},
390    45:function(){return this["qr-alnum"]},
391    58:function(){return this.Bitcoin},
392    60:function(){return this.appleII},
393  "low hex":   ["[0-9][a-f]"],															 // base 16
394  "qr-alnum":  ["[0-9][A-Z] $%*+-./:"],                      // base 45
395  "Bitcoin":   ["[1-9][A-H]JKLMN[P-Z][a-k][m-z]"],           // base 58
396  "appleII":   ["[0-9][A-Z]![#-/][:-@]]^ "],                 // base 60
397  "Base64":    ["[A-Z][a-z][0-9]+/"],												 // base 64
398  "GUID64":    ["[A-Z][a-z][0-9]-_"]                        // base 64
399  }
400
401  this.getsymbols = function(base) {
402    if(typeof(base)=="undefined") return "";
403    if(typeof(this.symbols[base])=="undefined")
404      this.symbols[base] = base<95?
405        (this.rng(base<64?"[0-9][A-Z][a-z]+":"[A-Z][a-z][0-9][!-/][:-@][[-`][{-~]").substring(0,base))
406      :(this.symbols[base] = base<216?
407        this.rng("[\x20-\x7e]\x80[\x82-\x8c]\x8e[\x91-\x9c]\x9e\x9f[\xa1-\xac][\xae-\xff]").substring(0,base)
408        :this.rng("[\x00-\xff]").substring(256-base,256));
409    if(typeof(this.symbols[base])=="function")  this.symbols[base] = this.symbols[base]();             // process references
410    if(typeof(this.symbols[base])=="object")    this.symbols[base] = this.rng(this.symbols[base][0]);  // process range_replace
411    return this.symbols[base];
412  }
413
414  this.rng = function(recipe) {
415    var _a = recipe.match(/\[.-.\]/); if(_a==null) return recipe; else { _a=[_a[0].charCodeAt(1),_a[0].charCodeAt(3)];
416    return this.rng(recipe.replace(RegExp("\\[(\\x"+("0"+_a[0].toString(16)).slice(-2)+"-\\x"+_a[1].toString(16)+")\\]","g"),
417    String.fromCharCode(..." ".repeat(_a[1]-_a[0]+1).split("").map((_e,_i)=>_i+_a[0])) )) }
418  }
419
420  this.ltrim = function(s) { return s.replace(/^ */,"") }
421  this.rtrim = function(s) { return s.replace(/ *$/,"") }
422  this.trim  = function(s) { return this.rtrim(this.ltrim(s)) }
423  this.stripHTML = function(s) { return s.replace(/(&nbsp;|<([^>]+)>)/ig,"") }
424 
425  this.padding = function(word_arr, padding_arr) 
426  {
427    let result = [], lim_word;
428    for (let i = 0; i < word_arr.length; i++)
429    {
430      word_arr[i] = this.unescapeHTML(word_arr[i]);
431      lim_word = word_arr[i].length > padding_arr[i] ? word_arr[i].substring(0, padding_arr[i]) : word_arr[i];  // Limit the word to its padding width if it exceeds the available space
432      if (i > 0) result.push('&nbsp;'.repeat(Math.max(0, padding_arr[i-1] - result[result.length-1].length ))); // Calculate padding and ensure it's non-negative (no negative space)
433      result.push(lim_word);                                                                                    // Add the word to the result array
434    }
435    return result.join('');                                                                                     // Join the array into a single string
436  }
437
438  /*
439  //+ Carlos R. L. Rodrigues
440  //@ http://jsfromhell.com/classes/math-parser [rev. #3]
441
442  this.MathParser = function()
443  {
444      var o = this, p = o.operator = {};
445      p["+"] = function(n, m){return n + m;}
446      p["-"] = function(n, m){return n - m;}
447      p["*"] = function(n, m){return n * m;}
448      p["/"] = function(m, n){return n / m;}
449      p["%"] = function(m, n){return n % m;}
450      p["^"] = function(m, n){return Math.pow(n, m);}
451      p["~"] = function(m, n){return Math.sqrt(n, m);}
452      o.custom = {}
453    p.f = function(s, n)
454    {
455          if(Math[s]) return Math[s](n);
456          else if(o.custom[s]) return o.custom[s].apply(o, n);
457          else throw new Error("Function \"" + s + "\" not defined.");
458      }
459    o.add = function(n, f){this.custom[n] = f;}
460  }
461
462  this.MathParser.prototype.eval = function(e, ig)
463  {
464      var v = [], p = [], i, _, a, c = 0, s = 0, x, t = !ig ? e.indexOf("^") : -1, d = null;
465      var cp = e, e = e.split(""), n = "0123456789.", o = "+-/*^%~", f = this.operator;
466      if(t + 1)
467          do{
468              for(a = "", _ = t - 1;  _ && o.indexOf(e[_]) < 0; a += e[_], e[_--] = ""); a += "^";
469              for(_ = t + 1, i = e.length; _ < i && o.indexOf(e[_]) < 0; a += e[_], e[_++] = "");
470              e = e.slice(0, t).concat((this.eval(a, 1) + "").split("")).concat(e.slice(t + 1));
471          }
472          while(t = cp.indexOf("^", ++t) + 1);
473      for(i = 0, l = e.length; i < l; i++){
474          if(o.indexOf(e[i]) > -1)
475              e[i] == "-" && (s > 1 || d === null) && ++s, !s && d !== null && (p.push(e[i]), s = 2), "+-".indexOf(e[i]) < (d = null) && (c = 1);
476          else if(a = n.indexOf(e[i]) + 1 ? e[i++] : "")
477      {
478              while(n.indexOf(e[i]) + 1) a += e[i++];
479              v.push(d = (s & 1 ? -1 : 1) * a), c && v.push(f[p.pop()](v.pop(), v.pop())
479) && (c = 0), --i, s = 0;
480          }
481      }
482      for(c = v[0], i = 0, l = p.length; l--; c = f[p[i]](c, v[++i]));
483      return c;
484  }
485
486  this.MathParser.prototype.parse = function(e)
487  {
488    var p = [], f = [], ag, n, c, a, o = this, v = "0123456789.+-*"+"/^%~(, )";
489    for(var x, i = 0, l = e.length; i < l; i++){
490      if(v.indexOf(c = e.charAt(i)) < 0) { for(a = c; v.indexOf(c = e.charAt(++i)) < 0; a += c); f.push((--i, a)) }
491      else if(!(c == "(" && p.push(i)) && c == ")")
492      {
493        if(a = e.slice(0, (n = p.pop()) - (x = v.indexOf(e.charAt(n - 1)) < 0 ? y = (c = f.pop()).length : 0)), x)
494          for(var j = (ag = e.slice(n, ++i).split(",")).length; j--; ag[j] = o.eval(ag[j]));
495        l = (e = a + (x ? o.operator.f(c, ag) : o.eval(e.slice(n, ++i))) + e.slice(i)).length, i -= i - n + c.length;
496      }
497    }
498    return o.eval(e);
499  }
500  */
501
502
503  this.MathParser = function()
504  {
505      p = this.operator = {};
506      p["+"] = function(n, m){return n + m}
507      p["-"] = function(n, m){return n - m}
508      p["*"] = function(n, m){return n * m}
509      p["/"] = function(m, n){return n / m}
510      p["%"] = function(m, n){return n % m}
511      p["^"] = function(m, n){return Math.pow(n, m)}
512      p["~"] = function(m, n){return Math.sqrt(n, m)}
513      p["&"] = function(m, n){return n & m}
514      p["|"] = function(m, n){return n | m}
515      p["x"] = function(m, n){return Number("0x"+m)}
516      this.custom = {}
517    p.f = function(s, n)
518    {
519      if(Math[s]) return Math[s](n);
520      else if(this.custom[s]) return this.custom[s].apply(this, n);
521      else throw new Error("Function \"" + s + "\" not defined.");
522    }
523    this.add = function(n, f){this.custom[n] = f;}
524
525    this.eval = function(e, ig)
526    {
527      var v = [], p = [], i, _, a, c = 0, s = 0, x, t = !ig ? e.indexOf("^") : -1, d = null;
528      var cp = e, e = e.split(""), n = "0123456789ABCDEF.", o = "+-/*^%~&|x", f = this.operator;
529      if(t + 1)
530        do
531        {
532          for(a = "", _ = t - 1;  _ && o.indexOf(e[_]) < 0; a += e[_], e[_--] = ""); a += "^";
533          for(_ = t + 1, i = e.length; _ < i && o.indexOf(e[_]) < 0; a += e[_], e[_++] = "");
534          e = e.slice(0, t).concat((this.eval(a, 1) + "").split("")).concat(e.slice(t + 1));
535        }
536        while(t = cp.indexOf("^", ++t) + 1);
537      for(i = 0, l = e.length; i < l; i++)
538      {
539        if(o.indexOf(e[i]) > -1)
540          e[i] == "-" && (s > 1 || d === null) && ++s, !s && d !== null && (p.push(e[i]), s = 2), "+-".indexOf(e[i]) < (d = null) && (c = 1);
541        else if(a = n.indexOf(e[i]) + 1 ? e[i++] : "")
542        {
543          while(n.indexOf(e[i]) + 1) a += e[i++];
544          v.push(d = (s & 1 ? -1 : 1) * a), c && v.push(f[p.pop()](v.pop(), v.pop())) && (c = 0), --i, s = 0;
545        }
546      }
547      for(c = v[0], i = 0, l = p.length; l--; c = f[p[i]](c, v[++i]));
548      return c;
549    }
550
551    this.parse = function(e)
552    {
553      var p = [], f = [], ag, n, c, a, o = this, v = "0123456789ABCDEF.+-*/^%~&|x(, )";
554      for(var x, i = 0, l = e.length; i < l; i++)
555      {
556        if(v.indexOf(c = e.charAt(i)) < 0) { for(a = c; v.indexOf(c = e.charAt(++i)) < 0; a += c); f.push((--i, a)) }
557        else if(!(c == "(" && p.push(i)) && c == ")")
558        {
559          if(a = e.slice(0, (n = p.pop()) - (x = v.indexOf(e.charAt(n - 1)) < 0 ? y = (c = f.pop()).length : 0)), x)
560            for(var j = (ag = e.slice(n, ++i).split(",")).length; j--; ag[j] = this.eval(ag[j]));
561          l = (e = a + (x ? o.operator.f(c, ag) : this.eval(e.slice(n, ++i))) + e.slice(i)).length, i -= i - n + c.length;
562        }
563      }
564      return this.eval(e);
565    }
566
567    this.format = function(str,substitutes)
568    {
569      var pos = str.lastIndexOf(",");
570      var out = str.substring(pos+1,str.length);
571      var str2 = str.substring(0,pos);
572
573      if(substitutes)
574        for(var ii in substitutes)
575          out = out.replace(RegExp(ii,"g"),substitutes[ii]);      // substitute all variables
576
577      var arr = String(this.parse(out)).split();
578      var i = -1;
579      function callback(exp, p0, p1, p2, p3, p4)
580      {
581        if (exp=='%%') return '%';
582        if (arr[++i]===undefined) return undefined;
583        exp  = p2 ? parseInt(p2.substr(1)) : undefined;
584        var base = p3 ? parseInt(p3.substr(1)) : undefined;
585        var val;
586        switch (p4)
587        {
588          case 's': val = arr[i]; break;
589          case 'c': val = arr[i][0]; break;
590          case 'f': val = parseFloat(arr[i]).toFixed(exp); break;
591          case 'p': val = parseFloat(arr[i]).toPrecision(exp); break;
592          case 'e': val = parseFloat(arr[i]).toExponential(exp); break;
593          case 'x': val = parseInt(arr[i]).toString(base?base:16); break;
594          case 'X': val = parseInt(arr[i]).toString(base?base:16).toUpperCase(); break;
595          case 'd': val = parseFloat(parseInt(arr[i], base?base:10).toPrecision(exp)).toFixed(0); break;
596        }
597        val = typeof(val)=='object' ? JSON.stringify(val) : val.toString(base);
598        var sz = parseInt(p1);                                          // padding size
599        var ch = p1 && p1[0]=='0' ? '0' : ' ';                          // isnull? 
600        while (val.length<sz) val = p0 !== undefined ? val+ch : ch+val; // isminus?
601        return val;
602      }
603      var regex = /%(-)?(0?[0-9]+)?([.][0-9]+)?([#][0-9]+)?([scfpexXd%])/g;
604      return str2.replace(regex, callback);
605    }
606
607  }
608
609  // https://stackoverflow.com/questions/1293147/how-to-parse-csv-data
610  this.CSVParser =
611  {
612    parse:
613      function(csv)
614      {
615        var arr = csv.match( /\s*(\"[^"]*\"|'[^']*'|[^,]*)\s*(,|$)/g ).map( 
616          function (csv)
617          {
618            let m;
619            if (m = csv.match(/^\s*,?$/)) return null; // null value
620            if (m = csv.match(/^\s*\"([^"]*)\"\s*,?$/)) return "\""+m[1]+"\""; // Double Quoted Text
621            if (m = csv.match(/^\s*'([^']*)'\s*,?$/)) return "'"+m[1]+"'"; // Single Quoted Text
622            if (m = csv.match(/^\s*(true|false)\s*,?$/)) return m[1] === "true"; // Boolean
623            if (m = csv.match(/^\s*((?:\+|\-)?\d+)\s*,?$/)) return parseInt(m[1]); // Integer Number
624            if (m = csv.match(/^\s*((?:\+|\-)?\d*\.\d*)\s*,?$/)) return parseFloat(m[1]); // Floating Number
625            if (m = csv.match(/^\s*(.*?)\s*,?$/)) return m[1]; // Unquoted Text
626          }
627        )
628        if(arr[arr.length-1]==null) arr.pop();
629        return arr;
630      }
631    ,
632    stringify:
633      function(table, replacer)
634      {
635          replacer = replacer || function(r, c, v) { return v; };
636          var csv = '', c, cc, r, rr = table.length, cell;
637          for (r = 0; r < rr; ++r)
638          {
639              if (r) csv += '\r\n';
640              for (c = 0, cc = table[r].length; c < cc; ++c)
641              {
642                if (c) csv += ',';
643                cell = replacer(r, c, table[r][c]);
644                if (/[,\r\n"]/.test(cell))
645                  cell = '"' + cell.replace(/"/g, '""') + '"';
646                csv += (cell || 0 === cell) ? cell : '';
647              }
648          }
649          return csv;
650      }
651  }
652
653  this.escapeHTML = function(str) 
654  {
655    return String(str)
656      .replaceAll("&", "&amp;")
657      .replaceAll("<", "&lt;")
658      .replaceAll(">", "&gt;")
659      .replaceAll("&lt;b&gt;", "<b>")
660      .replaceAll("&lt;/b&gt;", "</b>")
661      .replaceAll("&lt;u&gt;", "<u>")
662      .replaceAll("&lt;/u&gt;", "</u>")
663      .replaceAll("&lt;i&gt;", "<i>")
664      .replaceAll("&lt;/i&gt;", "</i>")
665      .replaceAll('"', "&quot;")
666      .replaceAll("'", "&#039;")
667      .replaceAll("\n","<br>")
668      .replaceAll(" ", "&nbsp;");
669  }
670
671  this.unescapeHTML = function(str)
672  {
673    return String(str)
674    .replace(/&nbsp;/g, " ")
675    .replace(/<br>/g,   "\n")
676    .replace(/&#039;/g, "'")
677    .replace(/&quot;/g, '"')
678    .replace(/&gt;/g,   ">")
679    .replace(/&lt;/g,   "<")
680    .replace(/&amp;/g,  "&");
681  }
682/*
683  this.unescapeHTML = function(str)
684  {
685    return str
686    .replace(/&quot;/g, '"')
687    .replace(/&lt;/g, '<')
688    .replace(/&gt;/g, '>')
689    .replace(/&nbsp;/g, '\u00A0')
690    .replace(/&amp;/g, '&');
691  }
692*/
693  this.escapeREGEXP = function(str)
694  {
695    return str
696    .replace(/([\.\^\$\*\+\-\?\(\)\[\]\{\}\\\|])/g, "\\$1")
697  }
698
699  this.uuid = function()  // UUID v4
700  {
701    return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
702      (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
703    );
704  }
705
706  this.default = function(src_obj,default_obj,message)  // create default object when main object is missing
707  {
708    try
709    {
710      var cn = this.default.caller.name;
711      //if(cn!="EMU_init")
712      //oCOM.POPUP.html("default["+cn+"] typeof(src_obj)="+typeof(src_obj)+" Object.keys(src_obj).length="+Object.keys(src_obj).length+" = "+(typeof(src_obj)=="undefined" || Ob
712ject.keys(src_obj).length==0 ? "default override" : "main"));
713
714      if(typeof(src_obj)=="undefined" || Object.keys(src_obj).length==0) { console.warn(cn+" : proceeding without "+message); return default_obj }
715      return src_obj;
716    }
717    catch({ name, message })
718    {
719        //oCOM.POPUP.html("error in oCOM.default ["+cn+"]: "+name+" "+message);
720    }
721  }
722
723 this.instance_of = function(V, F)
724 {
725    var O = F.prototype;
726    V = V.__proto__;
727    while (true)
728    {
729      if (V === null)
730        return false;
731      if (O === V)
732        return true;
733      V = V.__proto__;
734    }
735  }
736
737  this.toASCIIarr = function(str){for(var a=[],i=0;i<str.length;i++)a.push(str.charCodeAt(i));return a;}  // convert string into array of ascii codes (numbers)
738
739  this.crc16 = function(r){var crc=0xFFFF;var odd; for(var i=0;i<r.length;i++) { crc = crc ^ r[i]; for (var j = 0; j < 8; j++) { odd = crc & 0x0001; crc = crc >> 1; if (odd) { crc = crc ^ 0xA001 }}} return crc };
740  this.crc32 = function(r){for(var a,o=[],c=0;c<256;c++){a=c;for(var f=0;f<8;f++)a=1&a?3988292384^a>>>1:a>>>1;o[c]=a}for(var n=-1,t=0;t<r.length;t++)n=n>>>8^o[255&(n^r[t])];return(-1^n)>>>0};
741
742  /*
743   * General-purpose fixed-capacity ring buffer.
744   *
745   * _type is optional:
746   *   undefined/null : normal Array, accepts objects
747   *   8              : Uint8Array
748   *   16             : Uint16Array
749   *   32             : Uint32Array
750   *
751   * This replaces the older RingBuffer implementation while keeping the same
752   * high-level API: push/pop/shift/unshift/front/back/clear/slice/debug plus resize/fromArray/forEach.
753   */
754  var _COM_this = this;
755
756  this.buffer = function(_capacity,_type)
757  {
758    _capacity = Number(_capacity) | 0;
759    if(_capacity < 1) _capacity = 1;
760
761    var _typed = false;
762    var _buffer;
763
764    function _newBuffer(capacity)
765    {
766      switch(Number(_type))
767      {
768        case 8:  _typed = true; return new Uint8Array(capacity);
769        case 16: _typed = true; return new Uint16Array(capacity);
770        case 32: _typed = true; return new Uint32Array(capacity);
771        default: _typed = false; return new Array(capacity);
772      }
773    }
774
775    _buffer = _newBuffer(_capacity);
776
777    var _first  = 0;
778    var _length = 0;
779
780    function _wrap(index)
781    {
782      index = index % _capacity;
783      return index < 0 ? index + _capacity : index;
784    }
785
786    function _last()
787    {
788      return _wrap(_first + _length - 1);
789    }
790
791    function _clearIndex(index)
792    {
793      _buffer[index] = _typed ? 0 : undefined;
794    }
795
796    function _toArray()
797    {
798      var out = [];
799      for(var i = 0; i < _length; i++)
800        out.push(_buffer[_wrap(_first + i)]);
801      return out;
802    }
803
804    var api = 
805    {
806      get length()   { return _length; },
807      get capacity() { return _capacity; },
808      get first()    { return _first; },
809
810      // Backward-compatible debug fields.  Code should prefer length, capacity and first, but these keep the old RingBuffer shape readable.
811      get _first()   { return _first; },
812      set _first(v)  { _first = _wrap(Number(v) | 0); },
813      get _length()  { return _length; },
814      set _length(v) { _length = Math.max(0,Math.min(_capacity,Number(v) | 0)); },
815      get _capacity(){ return _capacity; },
816
817      debug: function()
818      {
819        return JSON.stringify({
820          buffer: _typed ? Array.from(_buffer) : _buffer.slice(),
821          first: _first,
822          length: _length,
823          capacity: _capacity,
824          typed: _typed,
825          type: _type || 0
826        });
827      },
828
829      clear: function()
830      {
831        for(var i = 0; i < _capacity; i++) _clearIndex(i);
832        _first = 0;
833        _length = 0;
834        return this;
835      },
836      empty: function() { return _length === 0; },
837      full:  function() { return _length === _capacity; },
838      front: function() { if(_length === 0) return undefined; else return _buffer[_first]; },
839      back: function() { if(_length === 0) return undefined; else return _buffer[_last()]; },
840      at: function(index) { index = Number(index) | 0; if(index < 0) index = _length + index; if(index < 0 || index >= _length) return undefined; else return _buffer[_wrap(_first + index)]; },
841      push: function(value) { if(_length === _capacity) this.shift(); _buffer[_wrap(_first + _length)] = value; _length++; return _length; },
842      pop: function() { if(_length === 0) return undefined; else { var index = _last(); var value = _buffer[index]; _clearIndex(index); _length--; return value; } },
843      shift: function() { if(_length === 0) return undefined; else { var value = _buffer[_first]; _clearIndex(_first); _first = _wrap(_first + 1); _length--; return value;} },
844      unshift: function(value) { if(_length === _capacity) this.pop(); _first = _wrap(_first - 1); _buffer[_first] = value; _length++; return _length; },
845
846      // Historical helper used as a delay line: insert newest value at the front and discard the oldest value when the buffer is full.
847      delay: function(value) { return this.unshift(value); },
848      resize: function(newCapacity)
849      {
850        newCapacity = Number(newCapacity) | 0;
851        if(newCapacity < 1) newCapacity = 1;
852        if(newCapacity === _capacity) return this;
853
854        var values = _toArray();
855
856        _capacity = newCapacity;
857        _buffer = _newBuffer(_capacity);
858        _first = 0;
859        _length = 0;
860
861        return this.fromArray(values);
862      },
863      fromArray: function(records)
864      {
865        this.clear();
866
867        if(!records) return this;
868        if(typeof records.length != "number") return this;
869
870        var start = Math.max(0,records.length - _capacity);
871        for(var i = start; i < records.length; i++)
872          this.push(records[i]);
873
874        return this;
875      },
876      forEach: function(callback,thisArg)
877      {
878        if(typeof callback != "function") return this;
879
880        for(var i = 0; i < _length; i++)
881          callback.call(thisArg,_buffer[_wrap(_first + i)],i,this);
882
883        return this;
884      },
885      slice: function(pos1,pos2) { return _toArray().slice(pos1,pos2); },
886      toArray: function() { return _toArray(); },
887      _last: function()   { if(_length === 0) return undefined; else return _last(); },
888      _right: function()  { _first = _wrap(_first + 1); return _first; },
889      _left: function()   { _first = _wrap(_first - 1); return _first; }
890    };
891
892    return api;
893  }
894
895  this.FIFO = function(length)
896  {
897    var pointer = 0, buffer = []; 
898  
899    return {
900      get  : function(key)
901      {
902        if (key < 0) return buffer[pointer+key];
903        else if (key === false) return buffer[pointer - 1];
904        else return buffer[key];
905      },
906      push : function(item)
907      {
908        buffer[pointer] = item;
909        pointer = (pointer + 1) % length;
910        return item;
911      },
912      prev : function()
913      {
914        var tmp_pointer = (pointer - 1) % length;
915        if (buffer[tmp_pointer]) { pointer = tmp_pointer; return buffer[pointer]; }
916      },
917      next : function()
918      {
919        if (buffer[pointer]) { pointer = (pointer + 1) % length; return buffer[pointer]; }
920      }
921    };
922  };
923
924  this.cloneJSON = function(obj)
925  {
926      return JSON.parse(JSON.stringify(obj));
927  }
928
929
930//   ██████  ██    ██ ██     ███████ ██    ██ ███    ██  ██████ ████████ ██  ██████  ███    ██ ███████ 
931//  ██       ██    ██ ██     ██      ██    ██ ████   ██ ██         ██    ██ ██    ██ ████   ██ ██      
932//  ██   ███ ██    ██ ██     █████   ██    ██ ██ ██  ██ ██         ██    ██ ██    ██ ██ ██  ██ ███████ 
933//  ██    ██ ██    ██ ██     ██      ██    ██ ██  ██ ██ ██         ██    ██ ██    ██ ██  ██ ██      ██ 
934//   ██████   ██████  ██     ██       ██████  ██   ████  ██████    ██    ██  ██████  ██   ████ ███████ 
935
936  /////////////////////////////////
937  // FIXED-COLUMN TEXT RENDERER  //
938  /////////////////////////////////
939
940  this.parseColumnSpec = function(spec, defaults)
941  {
942    defaults = defaults || {};
943    var out = {};
944
945    function put(k, v)
946    {
947      v = Number(v);
948      if (!Number.isFinite(v)) return;
949      out[String(k)] = v | 0;
950    }
951
952    for (var k in defaults) put(k, defaults[k]);
953
954    if (spec == null || spec === "") return out;
955
956    if (typeof spec === "object")
957    {
958      for (var kk in spec) put(kk, spec[kk]);
959      return out;
960    }
961
962    var text = String(spec).trim();
963    if (!text) return out;
964
965    try
966    {
967      var jsonish = text;
968      // Accept both JSON and compact object notation like {adr:0,code:6,lbl:16}.
969      if (jsonish.charAt(0) === "{" && jsonish.indexOf('"') < 0)
970        jsonish = jsonish.replace(/([A-Za-z_$][A-Za-z0-9_$-]*)\s*:/g, '"$1":');
971      var obj = JSON.parse(jsonish);
972      for (var j in obj) put(j, obj[j]);
973      return out;
974    }
975    catch (_ignore) {}
976
977    var re = /([A-Za-z_$][A-Za-z0-9_$-]*)\s*[:=]\s*(-?\d+)/g, m;
978    while ((m = re.exec(text))) put(m[1], m[2]);
979    return out;
980  };
981
982  this.textTableClip = function(text, width, ellipsis)
983  {
984    text = String(text == null ? "" : text);
985    ellipsis = ellipsis || "…";
986    if (width == null || !Number.isFinite(Number(width)) || Number(width) < 0) return text;
987    width = Number(width) | 0;
988    if (width === 0) return "";
989    if (text.length <= width) return text;
990    if (width <= ellipsis.length) return ellipsis.substring(0, width);
991    return text.substring(0, width - ellipsis.length) + ellipsis;
992  };
993
994  this.renderTextTableRows = function(rows, columnSpec, options)
995  {
996    options = options || {};
997    rows = rows || [];
998    var ellipsis = options.ellipsis || "…";
999    var trimRight = options.trimRight !== false;
1000    var singleColumnRaw = options.singleColumnRaw !== false;
1001    var defaults = options.defaults || {};
1002    var spec = this.parseColumnSpec(columnSpec, defaults);
1003    var order = (options.order || Object.keys(spec)).slice();
1004
1005    // Keep only configured keys and sort them by their start column. Ties keep order[] priority.
1006    order = order.filter(function(k) { return spec[k] != null; });
1007    order.sort(function(a, b) {
1008      var d = spec[a] - spec[b];
1009      return d || ((options.order || []).indexOf(a) - (options.order || []).indexOf(b));
1010    });
1011
1012    function rowToLine(row)
1013    {
1014      row = row || {};
1015      var nonEmpty = [];
1016      for (var i = 0; i < order.length; i++)
1017      {
1018        var key = order[i];
1019        var val = row[key];
1020        if (val != null && String(val) !== "") nonEmpty.push(key);
1021      }
1022
1023      // UX rule for comment-only or otherwise single-field rows: print at column 0.
1024      if (singleColumnRaw && nonEmpty.length === 1)
1025        return String(row[nonEmpty[0]]);
1026
1027      var line = "";
1028      for (var c = 0; c < order.length; c++)
1029      {
1030        var key = order[c];
1031        var text = row[key];
1032        if (text == null || String(text) === "") continue;
1033        var col = Number(spec[key]) | 0;
1034        var nextKey = null;
1035        for (var n = c + 1; n < order.length; n++)
1036        {
1037          if (spec[order[n]] > col) { nextKey = order[n]; break; }
1038        }
1039        var width = nextKey == null ? undefined : (Number(spec[nextKey]) - col);
1040        text = this.textTableClip(text, width, ellipsis);
1041        if (line.length < col) line = line + " ".repeat(col - line.length);
1042        if (line.length > col)
1043        {
1044          // Preserve an already-written overflowing field; move this field just after it.
1045          // Properly configured columns should not hit this path because clipping happens above.
1046          line += " ";
1047        }
1048        line += text;
1049      }
1050      return trimRight ? line.replace(/\s+$/, "") : line;
1051    }
1052
1053    var out = [];
1054    for (var r = 0; r < rows.length; r++) out.push(rowToLine.call(this, rows[r]));
1055    return out;
1056  };
1057
1058  this.renderTextTable = function(rows, columnSpec, options)
1059  {
1060    return this.renderTextTableRows(rows, columnSpec, options).join((options && options.lineBreak) || "\n");
1061  };
1062
1063
1064// Interactive JSON pretty printer, adapted from the JSON.prettify pattern used in AsciiCAD_CMD.js.
1065this.JSONprettify = function makeInteractiveJson(mountEl, boundaryStyles, indent)
1066{
1067    if (!mountEl) throw new Error("mountEl is required");
1068    indent = indent || 2;
1069    var jsonStr = mountEl.textContent;
1070
1071    var styleMap = Array.isArray(boundaryStyles)
1072        ? Object.fromEntries(boundaryStyles)
1073        : (boundaryStyles || {});
1074
1075    var openToKey = {};
1076    var closeToKey = {};
1077    Object.keys(styleMap).forEach(function (pairKey) {
1078        if (typeof pairKey !== "string" || pairKey.length !== 2) return;
1079        openToKey[pairKey.charAt(0)] = pairKey;
1080        closeToKey[pairKey.charAt(1)] = pairKey;
1081    });
1082
1083    var pretty;
1084    try {
1085        pretty = prettyJsonAllman(JSON.parse(jsonStr), indent);
1086    } catch (e) {
1087        var fallback = document.createElement("pre");
1088        fallback.className = "json-view";
1089        fallback.textContent = jsonStr;
1090        mountEl.replaceChildren(fallback);
1091        return fallback;
1092    }
1093
1094    var root = document.createElement("pre");
1095    root.className = "json-view";
1096    var stacks = {};
1097    var nextPairId = 1;
1098    var frag = document.createDocumentFragment();
1099
1100    function appendText(text) {
1101        if (text) frag.appendChild(document.createTextNode(text));
1102    }
1103
1104    function appendBoundarySpan(ch, pairKey, pairId) {
1105        var sp = document.createElement("span");
1106        sp.className = "json-boundary";
1107        sp.textContent = ch;
1108        sp.dataset.pair = String(pairId);
1109        if (styleMap[pairKey]) sp.setAttribute("style", styleMap[pairKey]);
1110        frag.appendChild(sp);
1111        return sp;
1112    }
1113
1114    var i = 0;
1115    while (i < pretty.length) {
1116        var ch = pretty.charAt(i);
1117
1118        if (openToKey[ch]) {
1119            var openPairKey = openToKey[ch];
1120            var pairId = nextPairId++;
1121            appendBoundarySpan(ch, openPairKey, pairId);
1122            if (!stacks[openPairKey]) stacks[openPairKey] = [];
1123            stacks[openPairKey].push(pairId);
1124            i++;
1125            continue;
1126        }
1127
1128        if (closeToKey[ch]) {
1129            var closePairKey = closeToKey[ch];
1130            var stack = stacks[closePairKey];
1131            if (stack && stack.length) appendBoundarySpan(ch, closePairKey, stack.pop());
1132            else appendText(ch);
1133            i++;
1134            continue;
1135        }
1136
1137        // Light token coloring on top of the boundary-pair highlighting.
1138        var rest = pretty.substring(i);
1139        var m;
1140        if ((m = rest.match(/^\s+|^[:,]/))) {
1141            appendText(m[0]);
1142            i += m[0].length;
1143            continue;
1144        }
1145        if ((m = rest.match(/^"(?:\\.|[^"\\])*"(?=\s*:)/))) {
1146            appendClassed(m[0], "json-key");
1147            i += m[0].length;
1148            continue;
1149        }
1150        if ((m = rest.match(/^"(?:\\.|[^"\\])*"/))) {
1151            appendClassed(m[0], "json-string");
1152            i += m[0].length;
1153            continue;
1154        }
1155        if ((m = rest.match(/^-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/))
1155) {
1156            appendClassed(m[0], "json-number");
1157            i += m[0].length;
1158            continue;
1159        }
1160        if ((m = rest.match(/^(true|false)\b/))) {
1161            appendClassed(m[0], "json-boolean");
1162            i += m[0].length;
1163            continue;
1164        }
1165        if ((m = rest.match(/^null\b/))) {
1166            appendClassed(m[0], "json-null");
1167            i += m[0].length;
1168            continue;
1169        }
1170
1171        appendText(ch);
1172        i++;
1173    }
1174
1175    function appendClassed(text, className) {
1176        var sp = document.createElement("span");
1177        sp.className = className;
1178        sp.textContent = text;
1179        frag.appendChild(sp);
1180    }
1181
1182    root.appendChild(frag);
1183    mountEl.replaceChildren(root);
1184
1185    var activePair = null;
1186    function cssEscape(s) {
1187        if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(String(s));
1188        return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
1189    }
1190    function setActive(pairId) {
1191        if (activePair === pairId) return;
1192        clearActive();
1193        activePair = pairId;
1194        root.querySelectorAll('.json-boundary[data-pair="' + cssEscape(pairId) + '"]').forEach(function (el) {
1195            el.classList.add("active");
1196        });
1197    }
1198    function clearActive() {
1199        if (activePair == null) return;
1200        root.querySelectorAll('.json-boundary[data-pair="' + cssEscape(activePair) + '"]').forEach(function (el) {
1201            el.classList.remove("active");
1202        });
1203        activePair = null;
1204    }
1205
1206    root.addEventListener("mousemove", function (e) {
1207        var b = e.target.closest(".json-boundary");
1208        if (!b || !root.contains(b)) { clearActive(); return; }
1209        setActive(b.dataset.pair);
1210    });
1211    root.addEventListener("mouseleave", clearActive);
1212    return root;
1213};
1214
1215function prettyJsonAllman(value, indent) {
1216    indent = indent || 2;
1217    var obj = (typeof value === "string") ? JSON.parse(value) : value;
1218    var s = JSON.stringify(obj, null, indent);
1219    s = s.replace(/^(\s*)"([^"]+)"\s*:\s*([\{\[])([\}\]])?\s*(,?)\s*$/gm,
1220        function (_m, ws, key, open, maybeClose, comma) {
1221            // Keep empty arrays/objects compact: "tag": [] and "sym": {}.
1222            if (maybeClose) return ws + '"' + key + '": ' + open + maybeClose + comma;
1223            // For non-empty object/array values, keep the existing Allman-style key break.
1224            return ws + '"' + key + '":\n' + ws + open;
1225        }
1226    );
1227    return s;
1228}
1229
1230  ///////////////////////////////////
1231  // ASCII / UTF-8 TEXT GRID       //
1232  ///////////////////////////////////
1233
1234  this.ASCII_GRID = function(cols,rows,fill)
1235  {
1236    const COLS = Math.max(1,Number(cols) || 1);
1237    const ROWS = Math.max(1,Number(rows) || 1);
1238    const FILL = (fill == null ? " " : String(fill)).charAt(0) || " ";
1239    const ascii = [];
1240 
1241    this.cols = COLS;
1242    this.rows = ROWS;
1243
1244    this.BOX_SINGLE = { tl:"┌", tr:"┐", bl:"└", br:"┘", h:"─", v:"│" };
1245    this.BOX_FAT    = { tl:"┏", tr:"┓", bl:"┗", br:"┛", h:"━", v:"┃" };
1246    this.BOX_DOUBLE = { tl:"╔", tr:"╗", bl:"╚", br:"╝", h:"═", v:"║" };
1247    this._currentStroke = [];
1248    this.undoStack = [];
1249
1250    for(var r=0;r<ROWS;r++)
1251    {
1252      var row = [];
1253      for(var c=0;c<COLS;c++) row.push(FILL);
1254      ascii.push(row);
1255    }
1256
1257    this.clear = function(ch)
1258    {
1259      ch = (ch == null ? FILL : String(ch).charAt(0)) || " ";
1260
1261      this._currentStroke = [];
1262      for(var c=0;c<COLS;c++)
1263        for(var r=0;r<ROWS;r++)
1264        {
1265          if(ascii[r][c]!=ch)
1266          {
1267            const cell = {"r":r,"c":c};
1268            const op   = {"ch":ch,"type":"place"};
1269            this.applyOpAtCell?.( cell , op ) ?? {};           // display character on grid & build undo buffer
1270          }
1271        }
1272      this.pushStrokeIfNonEmpty?.(this._currentStroke) ?? {};   // commit undo buffer
1273
1274
1275      return this;
1276    };
1277
1278    this.getCharAtCell  = function(r, c) { return ascii[r][c] }
1279    this.drawCharAtCell = function(r, c, ch) { ascii[r][c] = ch; }
1280
1281    /*
1282     * Visual-layer adapter for RetroAppleJS.
1283     * This is the hook AsciiCAD uses to update the grid and build undo data.
1284     */
1285    this.applyOpAtCell = function(cell,op)
1286     {
1287        if(!cell || cell.r < 0 || cell.r >= ROWS || cell.c < 0 || cell.c >= COLS) return;
1288      const prev = ascii[cell.r][cell.c];
1289      const next = (op.type === "place") ? op.ch : FILL;
1290      if (prev === next) return;
1291
1292      this._currentStroke.push({ r: cell.r, c: cell.c, prev, next });
1293      ascii[cell.r][cell.c] = next;
1294    }
1295
1296    this.pushStrokeIfNonEmpty = function(stroke)
1297    {
1298      if(stroke && stroke.length) this.undoStack.push(stroke.slice());
1299    }
1300
1301    this.draw = function()
1302    {
1303      /* no-op: RetroAppleJS renders this text grid into a readonly textarea */
1304    }
1305
1306    /*
1307     * AsciiCAD-compatible text placement.
1308     * Original API: cell(<c>,<r>,<string>)
1309     */
1310    this.cell = function(c, r, str)
1311    {
1312      if(r===undefined || c===undefined || str===undefined) return;  // safe escape if no arguments provided
1313      if (r < 0 || r >= ROWS || c < 0 || c >= COLS)
1314        throw new Error("Position out of bounds. Valid: col[0-" + (COLS - 1) + "], row[0-" + (ROWS - 1) + "]");
1315 
1316      this._currentStroke = [];
1317      for(var i=0,dx=0;i<str.length;i++,dx++)
1318      {
1319        var op = {};
1320        op.ch    = str.charAt(i);  if(op.ch=="\n") { dx=-1; r++; continue; }
1321        op.type  = "place";
1322        var cell = {"r":r,"c":c+dx}
1323        if(cell.c < COLS)
1324          this.applyOpAtCell(cell,op);                   // display character on grid & push coordinate to currentStroke
1325      }
1326      this.pushStrokeIfNonEmpty(this._currentStroke);     // feed undo buffer
1327      return this;
1328    }
1329
1330    /*
1331     * AsciiCAD-compatible box.
1332     * Original API: box(<c0>,<r0>,<c1>,<r1>, {kind:[boxStyle]})
1333     */
1334    this.box = function(c0,r0,c1,r1, modifiers)
1335    {
1336      //console.log("this.box("+c0+","+r0+","+c1+","+r1+","+JSON.stringify(modifiers)+")");
1337
1338      if(c0===undefined || r0===undefined || c1===undefined || r1===undefined) return;  // safe escape if no arguments provided
1339      if(modifiers===undefined) var modifiers = {kind:{ h:'─', v:'│', tl:'┌', tr:'┐', bl:'└', br:'┘' }};
1340      const path = this.buildBoxPath( {"c":c0,"r":r0} , {"c":c1,"r":r1} , modifiers);
1341
1342      // De-dup (corners overwrite edges)
1343      const m = new Map();
1344      for (const p of path)
1345      {
1346        if (p.r < 0 || p.r >= ROWS || p.c < 0 || p.c >= COLS) continue;
1347        m.set(p.r + ',' + p.c, p.ch);
1348      }
1349
1350      this._currentStroke = [];
1351      for (const [key, ch] of m) 
1352      {
1353        const [r, c] = key.split(',').map(Number);
1354        const cell = {"r":r,"c":c};
1355        const op = {"ch":ch,"type":"place"};
1356        this.applyOpAtCell( cell , op );                     // display character on grid & build undo buffer
1357      }
1358      this.pushStrokeIfNonEmpty(this._currentStroke);   // commit undo buffer 
1359    }
1360
1361    this.buildBoxPath = function(start, end, modifiers)
1362    {
1363      const style = modifiers.kind;
1364      const r0 = start.r, c0 = start.c;
1365      const r1 = end.r, c1 = end.c;
1366
1367      const top   = Math.min(r0, r1);
1368      const bot   = Math.max(r0, r1);
1369      const left  = Math.min(c0, c1);
1370      const right = Math.max(c0, c1);
1371
1372      const out = [];
1373
1374      // Degenerate: a line or a point — we still draw something useful
1375      if (top === bot && left === right) {
1376        out.push({ r: top, c: left, ch: style.tl }); // or '+'; your call
1377        return out;
1378      }
1379
1380      // Top edge
1381      for (let c = left + 1; c <= right - 1; c++) out.push({ r: top, c, ch: style.h });
1382      // Bottom edge
1383      if (bot !== top) {
1384        for (let c = left + 1; c <= right - 1; c++) out.push({ r: bot, c, ch: style.h });
1385      }
1386
1387      // Left edge
1388      for (let r = top + 1; r <= bot - 1; r++) out.push({ r, c: left, ch: style.v });
1389      // Right edge
1390      if (right !== left) {
1391        for (let r = top + 1; r <= bot - 1; r++) out.push({ r, c: right, ch: style.v });
1392      }
1393
1394      // Corners (only if box has width/height)
1395      out.push({ r: top, c: left, ch: style.tl });
1396      if (right !== left) out.push({ r: top, c: right, ch: style.tr });
1397      if (bot !== top) out.push({ r: bot, c: left, ch: style.bl });
1398      if (bot !== top && right !== left) out.push({ r: bot, c: right, ch: style.br });
1399
1400      return out;
1401    }
1402
1403    this.line = function(arg)
1404    {
1405      arg = arg || {};
1406      var path = arg.path || [];
1407      var k = arg.wire || this.BOX_SINGLE;
1408
1409      function step(a,b) { return a < b ? 1 : (a > b ? -1 : 0); }
1410
1411      var stroke = [];
1412      for(var i=1;i<path.length;i++)
1413      {
1414        var c0 = Number(path[i-1][0]), r0 = Number(path[i-1][1]);
1415        var c1 = Number(path[i][0]),   r1 = Number(path[i][1]);
1416
1417        var dc = step(c0,c1);
1418        while(c0 != c1)
1419        {
1420          this.applyOpAtCell({c:c0,r:r0},{ch:k.h,type:"place"});
1421          c0 += dc;
1422        }
1423
1424        var dr = step(r0,r1);
1425        while(r0 != r1)
1426        {
1427          this.applyOpAtCell({c:c0,r:r0},{ch:k.v,type:"place"});
1428          r0 += dr;
1429        }
1430
1431        this.applyOpAtCell({c:c1,r:r1},{ch:(c0 == c1 ? k.v : k.h),type:"place"});
1432      }
1433      this.pushStrokeIfNonEmpty(this._currentStroke);
1434
1435      return this;
1436    };
1437
1438    this.toString = function()
1439    {
1440      return ascii.map(function(row){ return row.join("").replace(/\s+$/,""); }).join("\n");
1441    };
1442  };
1443
1444  this.textGrid = function(cols,rows,fill)
1445  {
1446    return new this.ASCII_GRID(cols,rows,fill);
1447  };
1448
1449
1450  /////////////////////////////////
1451  // TAB SWITCHING / ANIMATION   //
1452  /////////////////////////////////
1453
1454  this.TAB_TWEEN = function()
1455  {
1456    this.jobs = new WeakMap();
1457
1458    this.ease = function(t)
1459    {
1460      /*
1461       * Small approximation of GSAP's default power1.out easing:
1462       * fast at the beginning, smooth near the end.
1463       */
1464      return 1 - Math.pow(1 - t,2);
1465    };
1466
1467    this.to = function(el,duration,vars)
1468    {
1469      if(!el) return;
1470
1471      duration = Math.max(0,Number(duration) || 0);
1472      vars = vars || {};
1473
1474      var previous = this.jobs.get(el);
1475      if(previous) cancelAnimationFrame(previous);
1476
1477      var start = performance.now();
1478      var from = {};
1479      var to = {};
1480
1481      for(var prop in vars)
1482      {
1483        if(prop == "duration") continue;
1484        from[prop] = parseFloat(getComputedStyle(el)[prop]) || 0;
1485        to[prop] = parseFloat(vars[prop]) || 0;
1486      }
1487
1488      var self = this;
1489
1490      function step(now)
1491      {
1492        var t = duration <= 0 ? 1 : Math.min(1,(now - start) / (duration * 1000));
1493        var e = self.ease(t);
1494
1495        for(var prop in to)
1496          el.style[prop] = (from[prop] + (to[prop] - from[prop]) * e) + "px";
1497
1498        if(t < 1) self.jobs.set(el,requestAnimationFrame(step));
1499        else self.jobs.delete(el);
1500      }
1501
1502      step(start);
1503    };
1504  }
1505
1506  this.TAB_SWITCHER = function(arg)
1507  {
1508    arg = arg || {};
1509
1510    this.menuID       = arg.menuID       || "#app ul";
1511    this.contentCL    = arg.contentCL    || ".tabs";
1512    this.defID        = arg.defID        || "";
1513    this.animDuration = arg.animDuration == null ? 0.1 : arg.animDuration;
1514    this.groups       = [];
1515    this.animator     = arg.animator || new _COM_this.TAB_TWEEN();
1516    this.readhash     = arg.readhash || function(event) {
1517      if(typeof event == "string") return event;
1518      if(event && typeof event.node == "string") return event.node;
1519      return location.hash.slice(1);
1520    };
1521
1522    this.init = function()
1523    {
1524      this.initGroupsOnly();
1525      this.bindClicks();
1526      this.switchTab();
1527      return this;
1528    };
1529
1530    this.initGroupsOnly = function()
1531    {
1532      var menus = document.querySelectorAll(this.menuID);
1533      this.groups = [];
1534
1535      for(var i = 0; i < menus.length; i++)
1536      {
1537        var group = this.createGroup(menus[i],i);
1538        if(group) this.groups.push(group);
1539      }
1540    };
1541
1542    this.createGroup = function(menuEl,index)
1543    {
1544      var links = [];
1545
1546      for(var i = 0; i < menuEl.childNodes.length; i++)
1547        if(menuEl.childNodes[i].tagName == "A" && menuEl.childNodes[i].href !== undefined)
1548          links.push(menuEl.childNodes[i]);
1549
1550      if(!links.length) return null;
1551
1552      var focusEl = menuEl.querySelector(".tab_div_end") || menuEl.querySelector(".focus-el");
1553      var contentEl = this.findContentForMenu(menuEl);
1554
1555      if(!focusEl || !contentEl) return null;
1556
1557      menuEl.style.setProperty("--tab-count",String(links.length));
1558
1559      /*
1560       * Keep the old second-slider markup inert.  The v42 implementation uses
1561       * one focus element per menu and moves it directly to the active link.
1562       */
1563      var focusEls = menuEl.querySelectorAll(".tab_div_end,.focus-el");
1564      for(var f = 0; f < focusEls.length; f++)
1565        if(focusEls[f] !== focusEl)
1566          focusEls[f].style.display = "none";
1567
1568      var defaultLink = null;
1569      for(var d = 0; d < links.length; d++)
1570        if(this.defID && links[d].id == this.defID)
1571          defaultLink = links[d];
1572
1573      if(!defaultLink) defaultLink = links[0];
1574
1575      return {
1576        index: index,
1577        menuEl: menuEl,
1578        focusEl: focusEl,
1579        contentEl: contentEl,
1580        links: links,
1581        defaultLink: defaultLink
1582      };
1583    };
1584
1585    this.findContentForMenu = function(menuEl)
1586    {
1587      var wrapper = menuEl.parentElement;
1588      var node = wrapper ? wrapper.nextElementSibling : null;
1589
1590      while(node)
1591      {
1592        if(node.classList && node.classList.contains("tabs"))
1593          return node;
1594        node = node.nextElementSibling;
1595      }
1596
1597      var parent = wrapper ? wrapper.parentElement : null;
1598      while(parent)
1599      {
1600        for(var i = 0; i < parent.children.length; i++)
1601          if(parent.children[i].classList && parent.children[i].classList.contains("tabs"))
1602            return parent.children[i];
1603
1604        parent = parent.parentElement;
1605      }
1606
1607      return null;
1608    };
1609
1610    this.bindClicks = function()
1611    {
1612      var self = this;
1613
1614      for(var i = 0; i < this.groups.length; i++)
1615      {
1616        for(var j = 0; j < this.groups[i].links.length; j++)
1617        {
1618          this.groups[i].links[j].onclick = function()
1619          {
1620            /*
1621             * The browser updates location.hash after this click.
1622             * setTimeout also handles clicks on the already-selected hash.
1623             */
1624            window.setTimeout(function(){ self.switchTab(); },0);
1625          };
1626        }
1627      }
1628    };
1629
1630    this.cleanTabId = function(id)
1631    {
1632      id = String(id || "").replace(/^#/,"");
1633      if(id.indexOf("_") > 0) id = id.split("_")[0];
1634      return id;
1635    };
1636
1637    this.linkTarget = function(link)
1638    {
1639      return link.hash ? link.hash.split("#")[1] : "";
1640    };
1641
1642    this.findTargetLink = function(group,target)
1643    {
1644      if(target)
1645      {
1646        for(var i = 0; i < group.links.length; i++)
1647          if(this.linkTarget(group.links[i]) == target)
1648            return group.links[i];
1649
1650        /*
1651         * Prefix match handles parent tabs when the hash points deeper:
1652         * #tab2-1 activates #tab2 in the parent group.
1653         */
1654        for(var j = 0; j < group.links.length; j++)
1655        {
1656          var linkId = this.linkTarget(group.links[j]);
1657          if(target.indexOf(linkId + "-") == 0)
1658            return group.links[j];
1659        }
1660      }
1661
1662      return group.defaultLink;
1663    };
1664
1665    this.switchTab = function(event)
1666    {
1667      if(!this.groups.length) this.initGroupsOnly();
1668
1669      var target = this.cleanTabId(this.readhash(event));
1670
1671      for(var i = 0; i < this.groups.length; i++)
1672      {
1673        var group = this.groups[i];
1674        var link = this.findTargetLink(group,target);
1675
1676        if(link)
1677        {
1678          this.switchTabHeader(group,link);
1679          this.switchTabContent(group,this.linkTarget(link));
1680        }
1681      }
1682      if(_COM_this.POPUP && typeof(_COM_this.POPUP.syncScopes) == "function")
1683        _COM_this.POPUP.syncScopes();
1684
1685      return true;
1686    };
1687
1688    this.switchTabHeader = function(group,link)
1689    {
1690      var menuRect = group.menuEl.getBoundingClientRect();
1691      var linkRect = link.getBoundingClientRect();
1692
1693      this.animator.to(group.focusEl,this.animDuration,{
1694        left:  linkRect.left - menuRect.left,
1695        width: linkRect.width
1696      });
1697    };
1698
1699    this.switchTabContent = function(group,sel_id)
1700    {
1701      var node = group.contentEl.childNodes;
1702
1703      for(var i 
1703= 0; i < node.length; i++)
1704      {
1705        if(!node[i].id || !node[i].classList) continue;
1706        if(node[i].id == sel_id) node[i].classList.add("active");
1707        else                     node[i].classList.remove("active");
1708      }
1709    };
1710  }
1711
1712  this.tabSwitch = function(arg)
1713  {
1714    return new this.TAB_SWITCHER(arg || {});
1715  }
1716
1717
1718  /////// GUI FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
1719
1720
1721  /////////////////////////////////////
1722  // WRITE VALUES TO ANY TAG ELEMENT //
1723  /////////////////////////////////////
1724
1725  this.writeDisplay = function(n,v,f)
1726	{
1727		// n = el, v = value, f = extra HTML
1728		var obj,tagname,bAppend = typeof(f)!="undefined";
1729		if(document.getElementById) { obj=document.getElementById(n); tagname = obj.tagName.toUpperCase() }
1730		else if (document.all) { obj=document.all[n] }
1731		if(!obj) return;
1732    
1733		switch(tagname)
1734		{
1735			case "INPUT":
1736				switch(f)
1737				{
1738					case "beforebegin":obj.value += v; break; 				    // less common
1739					case "afterbegin": obj.value = v + obj.value; break;	// less common
1740					case "afterend":   obj.value = v + obj.value; break;
1741					case "beforeend":  obj.value += v; break;
1742					default:  		   obj.value = v;
1743				}
1744			break;
1745			default:
1746				if(bAppend) obj.insertAdjacentHTML(f,v);
1747				else obj.innerHTML=v;
1748		}
1749	}
1750
1751  this.POPUP = {
1752    create: function(target_id) 
1753    { 
1754      var obj = Object.create(this);
1755      obj.target_id = target_id;
1756      obj.id="";
1757      obj.states={};
1758      obj.rules={};
1759      obj.scopes={};
1760      this.el(obj.target_id.dest).innerHTML = obj.target_html();
1761      return obj 
1762    },
1763    id:"",
1764    states:{},
1765    rules:{},
1766    scopes:{},
1767    target_id:{"html":"COM_popup","html_txt":"COM_popup_text","class":"appbut","float":false},
1768    target_html:function(){return "<div class=appbox id=\""+this.target_id.html+"\" style=\""+(this.target_id.float?"position: absolute; z-index: 3; left: 0px; top: 120px;":"")+"\" hidden=\"\"><div id=\""+this.target_id.html_txt+"\"></div></div>"},
1769    target_btn:function(arg){return "<div class=\"appbut\" onclick=\"oCOM.POPUP.toggle('"+this.target_id.html+"');document.getElementById('"+this.target_id.html_txt+"').innerHTML='';"+(arg===undefined?"":arg.onclick)+"\" style=\"text-align:center;float:right;\">x</div>"},
1770    el: function(id) { return document.getElementById(id) },
1771    addRule: function(id,function_obj) { this.rules[id] = function_obj },
1772    runRule: function(id) { this.id = id; this.rules[id](this) },
1773    set_state: function(id,val) { this.states[id] = val; this.syncScope(id); },    
1774    get_state: function(id){ return this.states[id]===undefined?this.el(id).classList.item(1):this.states[id]},
1775    update_state: function(id,el){ this.states[id] = el.hidden },
1776
1777    addScope: function(id,owner_id)
1778    {
1779      this.scopes[id] = owner_id;
1780
1781      var el = this.el(id);
1782      if(el && this.states[id]===undefined)
1783        this.states[id] = !!el.hidden;
1784
1785      this.syncScope(id);
1786    },
1787    scopeActive: function(owner_id)
1788    {
1789      var owner = this.el(owner_id);
1790      if(!owner || owner.hidden) return false;
1791
1792      /*
1793       * A popupHost can be visible itself while its top-level tab is inactive.
1794       * Find the direct child of a .tabs container and require its active class.
1795       */
1796      var node = owner;
1797      while(node && node.parentElement)
1798      {
1799        if(node.parentElement.classList &&
1800           node.parentElement.classList.contains("tabs"))
1801          return node.classList.contains("active");
1802
1803        node = node.parentElement;
1804      }
1805
1806      return true;
1807    },
1808    syncScope: function(id)
1809    {
1810      var owner_id = this.scopes[id];
1811      if(owner_id===undefined) return this.el(id);
1812
1813      var el = this.el(id);
1814      if(!el) return null;
1815
1816      if(this.states[id]===undefined)
1817        this.states[id] = !!el.hidden;
1818
1819      /*
1820       * states[id] is the user's requested state.
1821       * el.hidden may additionally be forced while the owner toolbox/tab is
1822       * inactive, without overwriting that requested state.
1823       */
1824      el.hidden = !!this.states[id] || !this.scopeActive(owner_id);
1825      return el;
1826    },
1827    syncScopes: function()
1828    {
1829      for(var id in this.scopes)
1830        if(Object.prototype.hasOwnProperty.call(this.scopes,id))
1831          this.syncScope(id);
1832    },
1833    on: function(id)
1834    { var el = this.el(id);
1835      if(!el) return null;
1836      this.states[id] = false; el.hidden = false; this.syncScopes(); return el;
1837    },
1838    off: function(id)
1839    { var el = this.el(id);
1840      if(!el) return null;
1841      this.states[id] = true; el.hidden = true; this.syncScopes(); return el;
1842    },
1843    toggle: function(id)
1844    {
1845      var el = this.el(id);
1846      if(!el) return null;
1847
1848      /*
1849       * A scoped popup may be physically hidden because its owner is inactive
1850       * while remaining logically open. Toggle that logical state.
1851       */
1852      var hidden = this.scopes[id]!==undefined && this.states[id]!==undefined
1853          ? !!this.states[id]
1854          : !!el.hidden;
1855
1856      this.states[id] = !hidden;
1857      el.hidden = this.states[id];
1858      this.syncScopes();
1859      return el;
1860    },
1861
1862    title_body_html: function(title_html, body_html, body_id, body_class)
1863    {
1864        return ""
1865            +"<div class='com_popup_title'>"
1866                +title_html
1867            +"</div>"
1868            +"<div"
1869                +(body_id ? " id='"+body_id+"'" : "")
1870                +" class='"+(body_class || "com_popup_body com_scroll_xy")+"'"
1871            +">"
1872                +body_html
1873            +"</div>";
1874    },
1875    get_class: function(el,idx) { return el.classList.item(idx===un
1875defined?0:idx) },
1876    set_class: function(el,class1,class2,bool)
1877    {
1878      el.hidden = bool;
1879      if(bool) {el.classList.replace(class2,class1);this.states[el.id]=class1}
1880      else {el.classList.replace(class1,class2);this.states[el.id]=class2}
1881      //if(bool) {el.classList.remove(class2);el.classList.add(class1);this.states[el.id]=class1}
1882      //else {el.classList.remove(class1);el.classList.add(class2);this.states[el.id]=class2}
1883    },
1884    toggle_class: function(el,class1,class2)
1885    {
1886      this.toggle(el.id);
1887      this.set_class(el,class1,class2,el.hidden);
1888    },
1889    wipe: function()
1890    {
1891       document.getElementById(this.target_id.html_txt).innerHTML = "";
1892    },
1893    html: function(html,ttl)
1894    {
1895      var e = oCOM.POPUP.toggle(this.target_id.html);
1896      if(e==null) return null;
1897      if(html==null) e.hidden = true;
1898      if(ttl) { setTimeout( COM_PopupHTML,ttl*1000 ); e.hidden = false };
1899      document.getElementById(this.target_id.html_txt).innerHTML += !e.hidden?(html+"<br>"):"";
1900    }
1901  }
1902 
1903  this.GetHTTP = function(url,responsetype,callback_function,arg,error_callback)
1904  {
1905    // random value (workaround to avoid caching)
1906    var r = ""; //"?"+btoa(Math.round(Math.random(1)*6*6*6)+"").replace(new RegExp("=","g"),"");
1907    const xhttp = new XMLHttpRequest();
1908    xhttp.responseType = responsetype;    // check: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType
1909    xhttp["arg"] = arg;
1910    xhttp.onload = callback_function;
1911    if(typeof error_callback=="function")
1912    {
1913      xhttp.onerror = error_callback;
1914      xhttp.ontimeout = error_callback;
1915    }
1916    xhttp.open("GET", url+r);
1917    xhttp.send();
1918  }
1919
1920
1921  /*
1922   * Shared GitHub-backed catalog browser.
1923   *
1924   * Consumers provide only the repository/path plus adapter callbacks.  The
1925   * directory request/cache, parent navigation and Name/Size rendering live
1926   * here so Disk II, Assembler and future catalog users do not duplicate it.
1927   */
1928  this.CATALOG = {
1929    cache:{},
1930    instances:{},
1931
1932    create:function(cfg)
1933    {
1934      cfg = Object.assign({
1935         id:"catalog"
1936        ,popupID:null
1937        ,title:"CATALOG"
1938        ,owner:""
1939        ,repo:""
1940        ,ref:"main"
1941        ,basepath:""
1942        ,path:""
1943        ,fileIcon:"fa fa-file"
1944        ,popupStyle:"position:absolute;z-index:3;left:800px;top:32px;width:450px;height:450px;text-align:left;padding:0px;margin:0px"
1945      },cfg || {});
1946
1947      cfg.popupID = cfg.popupID || (cfg.id + "_popup");
1948
1949      var inst = {
1950         id:cfg.id
1951        ,cfg:cfg
1952        ,arg:{
1953             id:cfg.id
1954            ,owner:cfg.owner
1955            ,repo:cfg.repo
1956            ,ref:cfg.ref
1957            ,basepath:cfg.basepath
1958            ,path:cfg.path || cfg.basepath
1959         }
1960      };
1961
1962      inst.open = function(arg) { return _COM_this.CATALOG.open(inst.id,arg); };
1963      inst.render = function(arg) { return _COM_this.CATALOG.render(inst.id,arg); };
1964      inst.close = function() { return _COM_this.CATALOG.close(inst.id); };
1965
1966      this.instances[inst.id] = inst;
1967      return inst;
1968    },
1969
1970    ensurePopup:function(popupID,cssText)
1971    {
1972      var popup = document.getElementById(popupID);
1973      if(popup) return popup;
1974
1975      popup = document.createElement("div");
1976      popup.id = popupID;
1977      popup.hidden = true;
1978      popup.className = "appbox com_popup_frame";
1979      popup.style.cssText = cssText ||
1980        "position:absolute;z-index:3;left:800px;top:32px;width:450px;height:450px;text-align:left;padding:0px;margin:0px";
1981      document.body.appendChild(popup);
1982      return popup;
1983    },
1984
1985    githubContentsURL:function(arg)
1986    {
1987      arg = arg || {};
1988      var ref = arg.ref || "main";
1989      var path = arg.path || "";
1990      return "https://api.github.com/repos/"
1991        + arg.owner + "/" + arg.repo + "/contents/" + path
1992        + "?ref=" + encodeURIComponent(ref);
1993    },
1994
1995    githubRawURL:function(arg)
1996    {
1997      arg = arg || {};
1998      var ref = arg.ref || "main";
1999      var rawPath = String(arg.path || "")
2000        .split("/")
2001        .map(function(p){ return encodeURIComponent(p); })
2002        .join("/");
2003
2004      return "https://raw.githubusercontent.com/"
2005        + arg.owner + "/" + arg.repo + "/"
2006        + encodeURIComponent(ref) + "/" + rawPath;
2007    },
2008
2009    isRateLimitResponse:function(xhr)
2010    {
2011      if(!xhr) return false;
2012
2013      var status = Number(xhr.status);
2014      var remaining = "", retryAfter = "";
2015      try
2016      {
2017        remaining = xhr.getResponseHeader("x-ratelimit-remaining") || "";
2018        retryAfter = xhr.getResponseHeader("retry-after") || "";
2019      }
2020      catch(e) {}
2021
2022      if(remaining == "0") return true;
2023      if(status != 403 && status != 429) return false;
2024
2025      var message = String(xhr.responseText || xhr.response || "");
2026      try
2027      {
2028        var json = JSON.parse(message);
2029        if(json && json.message) message = json.message;
2030      }
2031      catch(e) {}
2032
2033      return retryAfter != "" || /rate\s+limit/i.test(message);
2034    },
2035
2036    isOffline:function()
2037    {
2038      return typeof navigator != "undefined" && navigator.onLine === false;
2039    },
2040
2041    offlineDirectory:function(table,url)
2042    {
2043      if(!table) return null;
2044
2045      var entry = table[url];
2046      if(entry === undefined) return null;
2047
2048      if(Array.isArray(entry))
2049        return {list:_COM_this.cloneJSON(entry)};
2050
2051      return {
2052         list:_COM_this.cloneJSON(entry.list || [])
2053        ,arg:entry.arg === undefined ? undefined : _COM_this.cloneJSON(entry.arg)
2054      };
2055    },
2056
2057    offlineData:function(table,arg,fullPath,output)
2058    {
2059      if(!table) return null;
2060
2061      arg = arg || {};
2062      var rec = table[arg.path] || table[arg.name] || table[fullPath];
2063      if(rec === undefined) return null;
2064
2065      return _COM_this.decodeDataRecord(rec,output);
2066    },
2067
2068    pack:function(arg)
2069    {
2070      return encodeURIComponent(JSON.stringify(arg || {}));
2071    },
2072
2073    unpack:function(txt)
2074    {
2075      try { return JSON.parse(decodeURIComponent(String(txt || ""))); }
2076      catch(e) { return {}; }
2077    },
2078
2079    open:function(id,arg)
2080    {
2081      var inst = this.instances[id];
2082      if(!inst) return false;
2083      this.ensurePopup(inst.cfg.popupID,inst.cfg.popupStyle);
2084      oCOM.POPUP.on(inst.cfg.popupID);
2085      return this.render(id,arg);
2086    },
2087
2088    close:function(id)
2089    {
2090      var inst = this.instances[id];
2091      if(!inst) return false;
2092      oCOM.POPUP.off(inst.cfg.popupID);
2093      return true;
2094    },
2095
2096    navigate:function(id,packedArg)
2097    {
2098      return this.render(id,this.unpack(packedArg));
2099    },
2100
2101    select:function(id,packedArg)
2102    {
2103      var inst = this.instances[id];
2104      if(!inst) return false;
2105      var arg = this.unpack(packedArg);
2106      if(typeof inst.cfg.onFile == "function") return inst.cfg.onFile(arg,inst);
2107      return false;
2108    },
2109
2110    render:function(id,arg)
2111    {
2112      var inst = this.instances[id];
2113      if(!inst) return false;
2114
2115      arg = Object.assign({},inst.arg,arg || {});
2116      arg.id = inst.id;
2117      arg.owner = arg.owner || inst.cfg.owner;
2118      arg.repo = arg.repo || inst.cfg.repo;
2119      arg.ref = arg.ref || inst.cfg.ref || "main";
2120      arg.basepath = arg.basepath || inst.cfg.basepath || "";
2121      arg.path = arg.path || arg.basepath;
2122      inst.arg = Object.assign({},arg);
2123
2124      this.ensurePopup(inst.cfg.popupID,inst.cfg.popupStyle);
2125      if(typeof inst.cfg.onNavigate == "function") inst.cfg.onNavigate(Object.assign({},arg),inst);
2126
2127      var self = this;
2128      return this.list(inst,arg,function(err,list,resolvedArg)
2129      {
2130        if(err)
2131        {
2132          if(typeof inst.cfg.onError == "function") inst.cfg.onError(err,inst);
2133          else console.warn("Catalog directory listing failed",err);
2134          return false;
2135        }
2136        self.renderList(inst,list,resolvedArg);
2137      });
2138    },
2139
2140    list:function(inst,arg,callback)
2141    {
2142      var self = this;
2143      var url = this.githubContentsURL(arg);
2144
2145      function useFallback(reason)
2146      {
2147        if(typeof inst.cfg.offlineProvider != "function") return false;
2148        delete self.cache[url];
2149        var offline = inst.cfg.offlineProvider(url,Object.assign({},arg),reason);
2150        if(!offline) return false;
2151        callback(null,offline.list || offline,offline.arg || arg);
2152        return true;
2153      }
2154
2155      if(this.isOffline())
2156      {  
2157        if(useFallback("navigator.onLine=false")) return true;
2158        callback({
2159           status:0
2160          ,message:"Offline: no embedded catalog data for " + String(arg.path || "")
2161          ,url:url
2162        });
2163        return false;
2164      }
2165
2166      if(this.cache[url] !== undefined)
2167      {
2168        var cached = this.cache[url];
2169        callback(null,cached.list,Object.assign({},arg));
2170        return true;
2171      }
2172
2173      oCOM.GetHTTP(url,"text",function()
2174      {
2175        if(this.status != 200)
2176        {
2177          if(Number(this.status)==0 && useFallback("network error (HTTP 0)")) return;
2178          if(self.isRateLimitResponse(this) && useFallback("GitHub API rate limit (HTTP " + this.status + ")")) return;
2179          callback({status:this.status,message:this.responseText || this.response,url:url});
2180          return;
2181        }
2182
2183        try
2184        {
2185          var json = JSON.parse(this.responseText || this.response);
2186          if(!Array.isArray(json)) throw new Error("GitHub contents response is not a directory listing");
2187
2188          var list = json.map(function(e)
2189          {
2190            return {
2191               name:e.name
2192              ,path:e.path
2193              ,type:e.type
2194              ,size:e.size
2195              ,download_url:e.download_url
2196              ,html_url:e.html_url
2197            };
2198          });
2199
2200          self.cache[url] = {list:list};
2201          callback(null,list,Object.assign({},arg));
2202        }
2203        catch(e)
2204        {
2205          callback({status:this.status,message:e.message,url:url});
2206        }
2207      },null,function()
2208      {
2209        if(useFallback("network error")) return;
2210        callback({
2211           status:this.status || 0
2212          ,message:"Network error"
2213          ,url:url
2214        });
2215
2216
2217      });
2218
2219      return true;
2220    },
2221
2222    renderList:function(inst,list,arg)
2223    {
2224      var cfg = inst.cfg;
2225      if(typeof cfg.filter == "function") list = list.filter(cfg.filter);
2226
2227      var dirs = list.filter(function(e){ return e.type == "dir"; });
2228      var files = list.filter(function(e){ return e.type == "file"; });
2229      var rows = dirs.concat(files);
2230      var html = "";
2231
2232      if(arg.path != arg.basepath)
2233      {
2234        var parentArg = Object.assign({},arg);
2235        var parent = String(arg.path || "").split("/");
2236        parent.pop();
2237        parentArg.path = parent.join("/") || arg.basepath;
2238        if(arg.basepath && parentArg.path.indexOf(arg.basepath) !== 0) parentArg.path = arg.basepath;
2239
2240        html += '<div class="appbut" style="width:25px;cursor:pointer" title="Parent directory"'
2241          + ' data-cat-action="navigate" data-cat-arg="' + this.pack(parentArg) + '">'
2242          + '<i class="fa fa-arrow-alt-circle-up"></i></div>';
2243      }
2244
2245      html += '<div style="margin-top:6px;"><table style="width:100%;border-collapse:collapse;font-size:11px;">'
2246        + '<tr><th style="text-align:left;padding:2px 4px;">Name</th>'
2247        + '<th style="text-align:left;padding:2px 4px;">Size</th></tr>';
2248
2249      for(var i=0;i<rows.length;i++)
2250      {
2251        var row = rows[i];
2252        var isDir = row.type == "dir";
2253        var nextArg = Object.assign({},arg,row,{path:row.path});
2254        var packed = this.pack(nextArg);
2255        var icon = isDir ? "fa fa-folder" : cfg.fileIcon;
2256        var action = isDir ? "navigate" : "select";
2257
2258        html += '<tr>'
2259          + '<td style="text-align:left;vertical-align:top;border-top:1px solid #888;padding:2px 4px;">'
2260          + '<div style="cursor:pointer" data-cat-action="' + action + '" data-cat-arg="' + packed + '">'
2261          + '<i class="' + icon + '"></i> ' + _COM_this.escapeHTML(row.name) + '</div></td>'
2262          + '<td style="text-align:left;vertical-align:top;border-top:1px solid #888;padding:2px 4px;">'
2263          + _COM_this.escapeHTML(isDir ? "-" : row.size) + '</td></tr>';
2264      }
2265      html += '</table></div>';
2266
2267      var title = typeof cfg.titleHTML == "function"
2268        ? cfg.titleHTML(inst,arg)
2269        : "<span>" + _COM_this.escapeHTML(cfg.title || "CATALOG") + "</span>";
2270
2271      title = '<div style="display:flex;align-items:center;gap:6px;width:100%;min-width:0;">'
2272        + title
2273        + '<span style="flex:1 1 auto;"></span>'
2274        + '<div class="appbut" data-cat-close="1"'
2275        + ' style="text-align:center;flex:0 0 auto;">x</div>'
2276        + '</div>';
2277
2278      var popup = this.ensurePopup(cfg.popupID,cfg.popupStyle);
2279      popup.innerHTML = oCOM.POPUP.title_body_html(
2280         title
2281        ,html
2282        ,inst.id + "_body"
2283        ,"com_popup_body com_scroll_xy"
2284      );
2285
2286      var body = document.getElementById(inst.id + "_body");
2287      if(body)
2288      {
2289        body.onclick = function(ev)
2290        {
2291          var el = ev.target && ev.target.closest ? ev.target.closest("[data-cat-action]") : null;
2292          if(!el || !body.contains(el)) return;
2293          var action = el.getAttribute("data-cat-action");
2294          var packedArg = el.getAttribute("data-cat-arg") || "";
2295          if(action == "navigate") oCOM.CATALOG.navigate(inst.id,packedArg);
2296          else if(action == "select") oCOM.CATALOG.select(inst.id,packedArg);
2297        };
2298      }
2299
2300      var close = popup.querySelector("[data-cat-close='1']");
2301      if(close) close.onclick = function(ev){ ev.stopPropagation(); oCOM.CATALOG.close(inst.id); };
2302      return true;
2303    }
2304  };
2305
2306  this.Download = function(fileName, data)
2307  {
2308    if(data.length===undefined) return;
2309
2310    var ui8 = new Uint8Array(data.length);
2311    for(var i=0;i<data.length;i++)
2312      ui8[i] = data[i];
2313    var url, mimeType = 'application/octet-stream';
2314    var blob = new Blob([ui8], {type: mimeType});
2315    var url = window.URL.createObjectURL(blob);
2316
2317    console.log("downloadURL('"+url+"', '"+fileName+"')")
2318    downloadURL(url, fileName);
2319    setTimeout(function() {
2320      return window.URL.revokeObjectURL(url);
2321    }, 2000);
2322  }
2323
2324  function downloadURL(data, fileName) {
2325    var a;
2326    a = document.createElement('a');
2327    a.href = data;
2328    a.download = fileName;
2329    document.body.appendChild(a);
2330    a.style = 'display: none';
2331    a.click();
2332    a.remove();
2333  };
2334
2335  this.UploadData = new Array();
2336  this.Upload = function(elementId,callback)
2337  {
2338      this.UploadData = [];
2339      var file = document.getElementById(elementId).files[0];
2340      if (!file) return;
2341      oCOM.UploadName = file.name;
2342
2343      this.fread = new FileReader();
2344      this.fread.readAsArrayBuffer(file);
2345      if(typeof(callback)!="function") callback = function() { alert("test") }; // override if needed
2346      else this.callback = callback;
2347
2348      function handleEvent(obj)
2349      {
2350          return function(levent)
2351          {
2352            var data = new DataView(levent.target.result);
2353            var size = levent.target.result.byteLength;
2354            this.UploadData = Array(size);
2355            for (var i = 0; i < size; i++)
2356              obj.UploadData[i] = data.getUint8(i);
2357            obj.callback(obj.UploadData);
2358          }
2359      }
2360      this.fread.addEventListener('load',handleEvent(this));
2361
2362  }
2363  
2364
2365
2366  this.roughSizeOfObject = function(object) 
2367  {
2368    const stack = [object];
2369    const seen  = new WeakSet();
2370    let bytes = 0;
2371
2372    while (stack.length) 
2373    {
2374      const value = stack.pop();
2375      switch (typeof value) 
2376      {
2377        case 'boolean': bytes += 4; break;
2378        case 'string':  bytes += value.length * 2; break; // UTF-16 assumption
2379        case 'number':  bytes += 8; break;   // IEEE-754 double
2380        case 'bigint':  bytes += 8; break;   // very rough
2381        case 'symbol':
2382        case 'function':
2383        case 'undefined': break;
2384        case 'object':
2385            if (value === null) break;
2386            if (seen.has(value)) break;
2387            seen.add(value);
2388            bytes += 16;              // Count object overhead – completely engine-dependent
2389
2390            // Handle Maps, Sets, etc.
2391            if (value instanceof Map)         { stack.push(...value.keys(), ...value.values()); break; }
2392            if (value instanceof Set)         { stack.push(...value.values()); break; }
2393            if (ArrayBuffer.isView(value))    { bytes += value.byteLength; break; }
2394            if (value instanceof ArrayBuffer) { bytes += value.byteLength; break; }
2395
2396            // Walk own keys (including non-enumerable & symbols)
2397            for (const key of Reflect.ownKeys(value)) { bytes += key.toString().length * 2;  stack.push(value[key]);}
2398            break;
2399      }
2400    }
2401    return bytes;
2402  }
2403
2404
2405  this.SystemSelect = function(id,tab)
2406  {
2407    var def = _TABS[tab]["SYS"];
2408    var s = "<select onchange=\"document.getElementById('"+id+"').innerHTML=oCOM.onSystemSelect(this.value,'"+tab+"')\">";
2409    for(var i in _CFG_SYSCODE)
2410      s+= "<option value='"+i+"' "+(i==def?"selected":"")+">"+_CFG_SYSCODE[i].Model+"</option>";
2411    return(s+"</select>");
2412  }
2413
2414  this.onSystemSelect = function(val,tab)
2415  {
2416    _TABS[tab].SYS = val;
2417
2418    var s = "";
2419    for(var i in _CFG_SYSCODE[val])
2420    {
2421      if(i!="Model" && i!="ROMS")
2422      {
2423        if(_CFG_SYSCODE[val][i] && _CFG_SYSCODE[val][i].split(",").length>1)
2424        {
2425          this.onSysOptionSelect(i,tab,0);
2426          var name = tab+"_"+i
2427          s+="<div style=display:flex;>"
2428          +i+":"
2429          +"<button onclick=oCOM.onSysOptionSelect('"+i+"','"+tab+"',-1)><i class='fa fa-arrow-alt-circle-left'></i></button>"
2430          +"<input name='"+name+"' value='"+_CFG_SYSCODE[val][i].split(",")[0]+"' size=7>"  // TODO NAVIGATE THROUGH OPTIONS
2431          +"<button onclick=oCOM.onSysOptionSelect('"+i+"','"+tab+"',1)><i class='fa fa-arrow-alt-circle-right'></i></button>"
2432          +"</div>"
2433        }
2434        else
2435          s+= i+":"+_CFG_SYSCODE[val][i]+"<br>";
2436      }
2437    }
2438    return s;
2439  }
2440
2441  this.onSysOptionSelect = function(option,tab,up_dn)
2442  {
2443    var sys = _TABS[tab]["SYS"];
2444    var arr = _CFG_SYSCODE[sys][option].split(",");
2445    var idx = 0;
2446    if(typeof(_TABS[tab][option])!="undefined")
2447    {
2448      for(var i=0;i<arr.length;i++)
2449        if(arr[i]==_TABS[tab][option]) idx=i;
2450      idx += up_dn;
2451      if(idx<0) idx=arr.length-1;
2452      if(idx==arr.length) idx=0;
2453    }
2454    _TABS[tab][option] = arr[idx];
2455    var name = tab+"_"+option;
2456    var el = document.getElementsByName(name);
2457    if(el.length>0)
2458      el[0].value = arr[idx];
2459
2460    //var el = el[0];
2461    //el.value = arr[idx]
2462    //.val.value = arr[idx];
2463    //alert("_TABS['"+tab+"']['"+option+"'] = '"+arr[idx]+"'");
2464  }
2465
2466    /////////////////////
2467    // URL PARSER      //
2468    /////////////////////
2469
2470  this.URL =
2471  {
2472    url:"",
2473    uri:{},
2474    hash:"",
2475    merge: function (_uri1,_uri2)
2476    {
2477      var _o = new Array();
2478      for(_i in _uri1)
2479        _o[_i] = _uri1[_i];
2480      for(_i in _uri2)
2481        _o[_i] = _uri2[_i]; 
2482      return _o[_i];
2483    },
2484    addURI: function (_uri2)
2485    {
2486      for(_i in _uri2)
2487        if(_uri2[_i])
2488          this.uri[_i] = _uri2[_i]; // add or override
2489        else
2490          delete this.uri[_i];
2491    },
2492    getURI: function ()
2493    {
2494      var _str = ""
2495      for(_i in this.uri)
2496      {
2497        if(this.uri[_i])
2498          _str += (_str?"&":"?") + _i + "=" + this.uri[_i];
2499      }
2500      return _str;
2501    },
2502    parse: function (_url)
2503    {
2504      var _ppos = _url.lastIndexOf("#");
2505      if(_ppos>0) { _url = _url.substring(0,_ppos); this.hash = _url.hash }
2506      var _urlarr = _url?_url.split("?"):new Array("","");
2507      var _urlargs = _urlarr[1]?_urlarr[1].split("&"):new Array(_urlarr[0],"");
2508      this.url = _urlarr[0];
2509  
2510      this.uri = {};
2511      for(var _i=0;_i<_urlargs.length;_i++)
2512      {
2513        if(!(_urlargs[_i]===undefined))
2514        {
2515          var a = _urlargs[_i].split("=");
2516          this.uri[ a[0] ] = a[1];
2517        }
2518      }
2519    }
2520  }
2521
2522  /////////////////////
2523  // EVENT SEQUENCER //
2524  /////////////////////
2525
2526  this.addToEventStack = function(event,func)
2527  {
2528    console.log("addToEventStack("+event+" "+func.name+")");
2529    if(o_event==null) var o_event = {};
2530    o_event[event] = window[event];
2531    if (typeof window[event] != "function") window[event] = func;
2532    else { window[event] = function() { if (o_event[event]) o_event[event](); func() } };
2533  }
2534
2535    
2536  /////////////////////////////////
2537  // DASHBOARD REFRESH SEQUENCER //
2538  /////////////////////////////////
2539
2540  // array of functions is called at the pace of EMU_DashboardRefresh_s = Dashboard updates per second   
2541  this.RefreshEvent_arr = {};
2542  this.bRefreshEvent = false;
2543
2544  this.addRefreshEvent = function(func,name,active)
2545  {
2546    if(!this.RefreshEvent_arr[ name ]) console.log("addRefreshEvent("+name+")");
2547    else console.log("addRefreshEvent("+name+") -> already exists (no problem)");
2548    this.RefreshEvent_arr[ name ] = {"func":func,"active":active}
2549    this.checkActiveRefreshEvents();
2550  }
2551
2552  this.toggleRefreshEvent = function(name)
2553  {
2554    if(!this.RefreshEvent_arr[name]) return false;
2555    this.RefreshEvent_arr[ name ].active = !this.RefreshEvent_arr[ name ].active;
2556    this.checkActiveRefreshEvents();
2557    return this.RefreshEvent_arr[ name ].active;
2558  }
2559
2560  this.checkActiveRefreshEvents = function()
2561  {
2562    var bActive = false;      // if any of the events is active, keep bRefreshEvent = true 
2563    for(var _o in this.RefreshEvent_arr)
2564      if(this.RefreshEvent_arr[_o].active) bActive = true;
2565    this.bRefreshEvent = bActive;
2566  }
2567
2568  this.enableRefreshEvent = function(name, active)
2569  {
2570    if(!this.RefreshEvent_arr[name]) return false;
2571    this.RefreshEvent_arr[name].active = !!active;
2572    this.checkActiveRefreshEvents();
2573    return this.RefreshEvent_arr[name].active;
2574  }
2575}
2576
2577oCOM = new COM();
2578
2579var oMEMGRID = new function()
2580{
2581  if(typeof(this.bDebug)=="undefined") this.bDebug = false;
2582  this.oCOM = new COM();
2583
2584  const mem_gran = 8;  // block granularity in bits
2585  this.mem_gran = mem_gran;
2586
2587  this.build_mem_map = function(layout)
2588  {
2589    for(var i in layout)
2590    {
2591      var a = i.split("-"); var b = [parseInt(a[0],16),parseInt(a[1],16),a[0].length];
2592      for(var addr=b[0],s="";addr<b[1];addr+=1<<mem_gran)
2593      { 
2594        this.mem_pg[addr>>mem_gran] = layout[i][2];
2595        if(this.bDebug) console.log("this.mem_pg["+(addr>>mem_gran)+"] = "+layout[i][2]);
2596        s += (addr>>mem_gran)+"="+layout[i][2]+" ";
2597      }
2598      //console.log(a[0]+"-"+a[1]+" ("+s+")");
2599    }
2600  }
2601
2602  this.mem_pg = new Array(0x10000>>mem_gran);
2603
2604  this.line = function(start,len,step,digits)
2605  {
2606    var end = start+len*step;
2607    for(var j=start,a=[],ii=0;j<end;j+=step) a[ii++] = this.oCOM.getHexMulti(j,digits);
2608    return a;
2609  }
2610
2611  this.conf_grid = {id_prefix:"m",digits:4};
2612  this.build_grid = function(start,len,step,cnf)
2613  {
2614    if(cnf===undefined) var cnf = this.conf_grid;
2615    var table_id = cnf.table_id===undefined ? cnf.id_prefix : cnf.table_id;
2616    var s = "<table class=gtable style='display:inline-block;' id='gtable_"+table_id+"'>\n";
2617    var end = start+len*step;
2618    for(var i=start;i!=end;i+=step)
2619      s += "<tr><td>"+this.oCOM.getHexMulti(i,cnf.digits)+"</td>"
2620              +"<td id='"+cnf.id_prefix+this.line(i,16,256,cnf.digits).join("'></td><td id='"+cnf.id_prefix)+"'></td></tr>\n";
2621    return s+"</table>"
2622  }
2623
2624  this.relabel_grid_rows = function(table_id, labels)
2625  {
2626    var tbl = document.getElementById("gtable_"+table_id);
2627    if(tbl==null) return;
2628    var rows = tbl.getElementsByTagName("tr");
2629    for(var i=0;i<rows.length && i<labels.length;i++)
2630      if(rows[i].cells.length>0)
2631        rows[i].cells[0].textContent = labels[i];
2632  }  
2633
2634  this.paint_grid = function(layout,cnf)
2635  {
2636    var fix = this.conf_grid.id_prefix;
2637    if(cnf===undefined) cnf = this.conf_grid;
2638    fix = cnf.id_prefix;
2639    this.build_mem_map(layout);  // populate this.mem_pg array (not for display, but later lookup)
2640
2641    // PREPARE DISPLAY DATA
2642    var blk_col = {}, blk_txt = {} //, c = ""
2643    for(var i in layout)
2644    {
2645      var a = i.split("-"); var b = [parseInt(a[0],16),parseInt(a[1],16),a[0].length];
2646      for(var j=b[0];j<b[1];j+=parseInt("0100",16)) // iterate through layout address range
2647      { //c += ("0000"+j.toString(16)).slice(-4).toUpperCase()+" "+blk[i][0]+"<br>";
2648        //var idx = ("0000"+j.toString(16)).slice(-4).toUpperCase();
2649        var idx = this.oCOM.getHexMulti(j,b[2]); // address index
2650        blk_col[idx] = layout[i][0];             // populate background colors (indexed by address)
2651        blk_txt[idx] = "$"+idx+" "+layout[i][1]; // populate popup texts (indexed by address)
2652        if(this.bDebug) console.log("blk_col["+idx+"] = "+layout[i][0]);
2653        if(this.bDebug) console.log("blk_txt["+idx+"] = $"+idx+" "+layout[i][1]);
2654      }
2655    }
2656
2657    // PAINT GRID
2658    for(var i in layout)
2659    {
2660      var a = i.split("-");
2661      if(a.length==2)
2662      {
2663        var b = [parseInt(a[0],16),parseInt(a[1],16),a[0].length];
2664        for(var j=b[0];j<b[1];j+=parseInt("0100",16))
2665        {
2666          //var idx = ("000"+j.toString(16)).slice(-4).toUpperCase();
2667          var idx = this.oCOM.getHexMulti(j,b[2]);
2668          var el = document.getElementById(fix+idx);
2669          if(el!=null)
2670          {
2671            if(blk_col[idx].charAt(0)=="#" || blk_col[idx].substring(0,5)=="rgba(")
2672              el.style.backgroundColor = blk_col[idx];
2673            el.innerHTML = "<span class=gt>"+blk_txt[idx]+"</span>"
2674          }
2675        }
2676      }
2677    }
2678  }
2679
2680  this.update_grid = function(mem_mon,cnf,bgcolor)
2681  {
2682    if(mem_mon===undefined || mem_mon==null) return;
2683    if(cnf===undefined) cnf = this.conf_grid;
2684    if(bgcolor===undefined) bgcolor = "#FFFFFF";
2685
2686    var fix = cnf.id_prefix;
2687    var digits = cnf.digits===undefined ? this.conf_grid.digits : cnf.digits;
2688
2689    for(var i in mem_mon)
2690    {
2691      if(!mem_mon[i]) continue;
2692
2693      var addr = Number(i) << mem_gran;
2694      var idx = this.oCOM.getHexMulti(addr,digits);
2695      var el = document.getElementById(fix + idx);
2696      if(el!=null) el.style.backgroundColor = bgcolor;
2697    }
2698  }
2699
2700}(COM)
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713//     _______                        
2714//    |_   __ \                       
2715//      | |__) |,--.   _ .--.  .---.  
2716//      |  ___/`'_\ : [ `.-. |/ /__\\ 
2717//     _| |_   // | |, | | | || \__., 
2718//    |_____|  \'-;__/[___||__]'.__.' 
2719
2720
2721function PANE()
2722{
2723     this.rootClass = "com-pane";
2724     this.titleClass = "com-pane-title";
2725     this.titleEqualizedClass = "com-pane-title-equalized";
2726
2727    /*
2728     * Common pane skin owned by PANE().
2729     * Page files should not define pane/header/body fonts, colors, dark-mode
2730     * foreground/background, or pane background patterns.  They should only
2731     * provide layout constraints, content, ids, and callbacks.
2732     */
2733    this.dotMatrixImage = 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAkkAAAA0AgMAAADjzUSRAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAewgAAHsIBbtB1PgAAAAxQTFRF9PT04PDr2NrX4uXjoOJ1gwAAARpJREFUeJzt2CEOwjAUxvGG59cLETgFdr3KLvE8egqN2Y6CxyAJIZR9jGWtgNS9T/SvOvcLJS97c25KepLcmgxDZOiVmDZj5wjyTfJQTT+rprLoTTJ0Vo40n84C6TsrR1qTzky+u9sdiUzb+RjvPCY5jzgJk8nHE04SI5PpghOVaR9vOHGanjzzyc8mN5lo5vhi8iPP3Ukcvw88prVq+lk1lZW/01nvmkuZabBeN9Ejf6fTA0GhqaaSqqksepMwmnq19qA2/06n1h4U8u90au1BId+D1dqDYLrOJuycau1BAftd9/kzcZnW/U6tPShgv7sQmpbfiWcWpHuwWntQm+7BPHe37JzYg9XagzCfIu8cr6Y/VVNZgXgPfgPwisbf2JoIlwAAAABJRU5ErkJggg==")';
2734
2735    this.paneCSS = function()
2736    {
2737        return ''
2738        + '.com-pane-host{display:grid;grid-template-columns:repeat(var(--com-pane-visible-count,1),minmax(0,1fr));gap:10px;width:100%;align-items:start;}'
2739        + '.com-pane-host>.com-pane[hidden]{display:none!important;}'
2740        + '.com-pane{min-width:0;max-width:100%;display:flex;flex-direction:column;gap:2px;border-radius:10px;overflow:visible;'
2741            + '--com-pane-light-header-bg:rgba(244,244,244,.88);--com-pane-light-header-fg:#000;--com-pane-light-header-border:rgba(0,0,0,.18);'
2742            + '--com-pane-light-body-bg:#f4f4f4;--com-pane-light-body-fg:#000;--com-pane-light-body-border:rgba(0,0,0,.12);'
2743            + '--com-pane-dark-header-bg:#111820;--com-pane-dark-header-fg:#e6edf3;--com-pane-dark-header-border:#30363d;'
2744            + '--com-pane-dark-body-bg:#000;--com-pane-dark-body-fg:#e6edf3;--com-pane-dark-body-border:#30363d;'
2745            + '--com-pane-body-font:500 12px/21px courier,monospace;--com-pane-title-font:12px/20px arial,sans-serif;'
2746            + '--com-pane-pattern-image:' + this.dotMatrixImage 
2746+ ';--com-pane-pattern-size:585px 42px;}'
2747        + '.com-pane-title{display:flex;align-items:center;justify-content:space-between;gap:10px;min-height:34px;margin:0 0 2px 0;padding:4px 8px;'
2748            + 'border-radius:10px;border:1px solid var(--com-pane-light-header-border);background:var(--com-pane-light-header-bg);color:var(--com-pane-light-header-fg);'
2749            + 'font:var(--com-pane-title-font);box-sizing:border-box;overflow:visible;position:relative;}'
2750        + '.com-pane-title-left{display:flex;align-items:center;gap:8px;min-width:0;white-space:nowrap;}'
2751        + '.com-pane-title-right{display:inline-flex;align-items:center;justify-content:flex-end;gap:4px;flex-wrap:nowrap;margin-left:auto;}'
2752        + '.com-pane-title strong{color:inherit;font-size:13px;font-weight:700;}'
2753        + '.com-pane-info{color:rgba(0,0,0,.70);font:11px/1.2 arial,sans-serif;white-space:nowrap;}'
2754        + '.com-pane-action{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:2px;margin:0;border:0;border-radius:3px;'
2755            + 'background:rgba(255,255,255,.4);color:inherit;cursor:pointer;font:13px/1 arial,sans-serif;}'
2756        + '.com-pane-action:hover{background:rgba(255,255,255,.75);}'
2757        + '.com-pane-body{width:100%;max-width:100%;min-height:var(--com-pane-body-min-height,420px);height:var(--com-pane-body-height,auto);'
2758            + 'padding:0 30px;border:1px solid var(--com-pane-light-body-border);border-radius:10px;box-sizing:border-box;'
2759            + 'background-color:var(--com-pane-light-body-bg);color:var(--com-pane-light-body-fg);font:var(--com-pane-body-font);tab-size:4;white-space:pre;'
2760            + 'overflow-x:auto;overflow-y:var(--com-pane-body-overflow-y,hidden);resize:none;}'
2761        + '.com-pane-body-pattern{background-image:var(--com-pane-pattern-image);background-size:var(--com-pane-pattern-size);background-attachment:local;background-repeat:repeat;}'
2762        + '.com-pane-body-plain{background-image:none;}'
2763        + '.com-pane-body-auto-grow{height:auto;overflow-y:hidden;}'
2764        + '.com-pane-body-scroll{overflow-y:auto;}'
2765        + '.com-pane-theme-dark .com-pane-title{border-color:var(--com-pane-dark-header-border);background:var(--com-pane-dark-header-bg);color:var(--com-pane-dark-header-fg);}'
2766        + '.com-pane-theme-dark .com-pane-info{color:rgba(230,237,243,.72);}'
2767        + '.com-pane-theme-dark .com-pane-action{background:rgba(255,255,255,.08);color:var(--com-pane-dark-header-fg);}'
2768        + '.com-pane-theme-dark .com-pane-action:hover{background:rgba(255,255,255,.16);}'
2769        + '.com-pane-theme-dark .com-pane-body{border-color:var(--com-pane-dark-body-border);background-color:var(--com-pane-dark-body-bg);background-image:var(--com-pane-dark-body-image,none);color:var(--com-pane-dark-body-fg);caret-color:var(--com-pane-dark-body-fg);}'
2770        + '.com-pane-theme-dark .com-pane-body *{color:var(--com-pane-dark-body-fg);}'
2771        + '.com-pane-theme-dark .com-pane-highlight-current{background:rgba(255,214,64,.62)!important;color:#000!important;}'
2772        + '.com-pane-theme-dark .com-pane-highlight-current *{color:#000!important;}';
2773    };
2774
2775    this.injectPaneCSS = function()
2776    {
2777        if(document.getElementById("com_pane_core_css")) return;
2778        var style = document.createElement("style");
2779        style.id = "com_pane_core_css";
2780        style.textContent = this.paneCSS();
2781        document.head.appendChild(style);
2782    };
2783
2784    this.setTheme = function(root, theme)
2785    {
2786        if(typeof root == "string") root = document.getElementById(root);
2787        if(!root) return false;
2788        var dark = (theme === true || theme === "dark");
2789        root.classList.toggle("com-pane-theme-dark", dark);
2790        root.classList.toggle("com-pane-theme-light", !dark);
2791        return dark;
2792    };
2793
2794    this.toggleTheme = function(root)
2795    {
2796        if(typeof root == "string") root = document.getElementById(root);
2797        if(!root) return false;
2798        return this.setTheme(root, !root.classList.contains("com-pane-theme-dark"));
2799    };
2800
2801    this.syncVisiblePanes = function(host)
2802    {
2803        if(typeof host == "string") host = document.getElementById(host);
2804        if(!host) return 0;
2805        var panes = Array.prototype.slice.call(host.children).filter(function(el){
2806            return el.classList && el.classList.contains("com-pane") && !el.hidden && getComputedStyle(el).display !== "none";
2807        });
2808        host.style.setProperty("--com-pane-visible-count", Math.max(1, panes.length));
2809        return panes.length;
2810    };
2811
2812    this.actionButtonHTML = function(action)
2813    {
2814        action = action || {};
2815        return '<button type="button" class="com-pane-action ' + this.escapeHTML(action.class || "") + '"'
2816            + (action.id ? ' id="' + this.escapeHTML(action.id) + '"' : '')
2817            + (action.act ? ' data-pane-default-act="' + this.escapeHTML(action.act) + '"' : '')
2818            + (action.onclick ? ' onclick="' + this.escapeHTML(action.onclick) + '"' : '')
2819            + (action.title ? ' title="' + this.escapeHTML(action.title) + '"' : '')
2820            + '>' + (action.html || (action.icon ? '<i class="' + this.escapeHTML(action.icon) + '"></i>' : this.escapeHTML(action.text || ""))) + '</button>';
2821    };
2822
2823    this.headerHTML = function(header)
2824    {
2825        header = header || {};
2826        var left = header.leftHTML || ('<strong>' + this.escapeHTML(header.title || "") + '</strong>'
2827            + (header.info ? '<span class="com-pane-info"' + (header.infoId ? ' id="' + this.escapeHTML(header.infoId) + '"' : '') + '>
2827' + this.escapeHTML(header.info) + '</span>' : '')
2828            + (header.inputHTML || ""));
2829        var actions = (header.actions || []).map(this.actionButtonHTML.bind(this)).join("");
2830        var right = header.rightHTML || actions;
2831        return '<div class="com-pane-title ' + this.escapeHTML(header.class || "") + '">'
2832            + '<div class="com-pane-title-left">' + left + '</div>'
2833            + '<div class="com-pane-title-right">' + right + '</div>'
2834            + '</div>';
2835    };
2836
2837    this.bodyClass = function(body)
2838    {
2839        body = body || {};
2840        return this.classHTML(
2841            "com-pane-body",
2842            body.pattern === false ? "com-pane-body-plain" : "com-pane-body-pattern",
2843            body.grow ? "com-pane-body-auto-grow" : "",
2844            body.scroll ? "com-pane-body-scroll" : "",
2845            body.class || ""
2846        );
2847    };
2848
2849    this.getHTML = function(txt, cfg)
2850     {
2851        this.injectPaneCSS();
2852        cfg = cfg || {};
2853        var header = cfg.header || {};
2854        var body   = cfg.body || {};
2855
2856        var sectionClass = this.classHTML(this.rootClass, cfg.class || "");
2857        var bodyClass = this.bodyClass(body);       
2858
2859        return ''
2860            + '<section'
2861            + (cfg.id ? ' id="' + this.escapeHTML(cfg.id) + '"' : '')
2862            + ' class="' + this.escapeHTML(sectionClass) + '">'
2863            + (header.html || this.headerHTML(header))
2864            + '<' + btag
2865            + (body.id ? ' id="' + this.escapeHTML(body.id) + '"' : '')
2866            + ' class="' + this.escapeHTML(bodyClass) + '"'
2867            + (body.style ? ' style="' + this.escapeHTML(this.styleHTML(body.style)) + '"' : '')
2868            + this.attrHTML(body.attr)
2869            + '>'
2870            + this.escapeHTML(content || "")
2871            + '</' + btag + '>'
2872            + '</section>';
2873    };
2874
2875    /*
2876     * Visual-Studio-style Find / Replace for textarea panes.
2877     *
2878     * Usage:
2879     *   oPANE.bindFindReplace("sourcePane", {
2880     *       barWidth:"400px",
2881     *       debounce_ms:500,
2882     *       onChange:function(target){ ... }
2883     *   });
2884     *
2885     * Notes:
2886     *   - This replaces the older pane-searchbar/bindSearch UI.
2887     *   - The target textarea is wrapped in an overlay container so matches and
2888     *     persistent "find in selection" can remain visible while focus is in
2889     *     the Find input.
2890     *   - Regex replacement supports \n, \r, \t, \\, $&, $$, $1..$99.
2891     */
2892    this.findReplaceCSS = function()
2893    {
2894        return ''
2895        + '.com-pane-fr-wrap{position:relative;width:100%;height:100%;min-height:0;overflow:hidden;}'
2896        + '.com-pane-fr-wrap>textarea,.com-pane-fr-overlay{position:absolute;inset:0;width:100%;height:100%;margin:0;border:0;outline:0;'
2897            + 'padding:14px;font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;'
2898            + 'tab-size:4;white-space:pre;overflow:auto;}'
2899
2900        + '.com-pane-fr-overlay{pointer-events:none;color:inherit;background:transparent;}'
2901        + '.com-pane-fr-wrap>textarea{resize:none;background:transparent!important;color:transparent!important;caret-color:currentColor;}'
2902        + '.com-pane-fr-wrap>textarea::selection{background:rgba(197,139,27,.65);color:transparent;}'
2903        + '.com-pane-fr-selection{background:rgba(190,190,190,.35);}'
2904        + '.com-pane-fr-match{background:rgba(255,210,70,.28);}'
2905        + '.com-pane-fr-current{background:rgba(255,185,45,.85);color:#000;}'
2906        + '.pane-title.com-pane-title-equalized{height:var(--com-pane-title-height,auto);min-height:var(--com-pane-title-height,auto);}'
2907        + '.com-pane-findreplace{z-index:2000;display:none;'        
2908            + 'grid-template-columns:auto minmax(150px,392px) auto auto auto auto auto;grid-template-rows:24px;'
2909            + 'align-items:center;gap:4px;padding:2px 4px;border:1px solid var(--com-pane-fr-border,#d2d2d2);'
2910            + 'border-radius:4px;background:var(--com-pane-fr-bg,#f3f3f3);box-shadow:0 2px 12px rgba(0,0,0,.18);'
2911            + 'color:var(--com-pane-fr-text,#5f5f5f);}'
2912        + '.com-pane-findreplace.com-pane-fr-inline{position:relative;top:auto;left:auto;right:auto;box-shadow:none;}'
2913        + '.com-pane-findreplace.com-pane-fr-overlay-expand.expanded{position:absolute;right:8px;top:2px;transform:none;box-shadow:0 2px 12px rgba(0,0,0,.35);}'
2914        + '.com-pane-findreplace.open{display:grid;}'
2915        + '.com-pane-findreplace.expanded{grid-template-rows:24px 24px;}'
2916        + '.com-pane-header-widget-host{display:flex;align-items:center;justify-content:flex-end;margin-left:auto;min-width:0;position:relative;}'
2917        + '.com-pane-header-widget{display:none;}'
2918        + '.com-pane-header-widget.active{display:flex;}'
2919        + '.com-pane-findreplace.com-pane-header-widget.active{display:grid;}'
2920        + '.com-pane-header-default-widget{align-items:center;gap:4px;}'
2921        + '.com-pane-header-default-widget .com-pane-fr-btn{display:inline-flex;}'
2922        + '.com-pane-fr-expander,.com-pane-fr-btn{display:inline-flex;align-items:center;justify-content:center;flex:0 0 20px;'
2923            + 'width:20px;min-width:20px;height:20px;padding:0;margin:0;border:1px solid transparent;border-radius:3px;'
2924            + 'background:transparent;color:var(--com-pane-fr-muted,#9a9a9a);
2924cursor:pointer;font:14px/1 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;'
2925            + 'text-align:center;vertical-align:middle;}'
2926        + '.com-pane-fr-expander{color:var(--com-pane-fr-expander,#333);}'
2927        + '.com-pane-fr-expander:hover,.com-pane-fr-expander.active,.com-pane-fr-btn.active{border-color:var(--com-pane-fr-active,#0078d4);background:var(--com-pane-fr-active-bg,#e9f4ff);color:var(--com-pane-fr-active-text,#111);}'
2928        + '.com-pane-fr-btn:hover{color:var(--com-pane-fr-hover-text,#555);background:var(--com-pane-fr-hover-bg,#ececec);}'
2929        + '.com-pane-fr-btn:disabled{opacity:.35;cursor:default;pointer-events:none;}'
2930        + '.com-pane-fr-expander .chev{display:inline-flex;align-items:center;justify-content:center;line-height:1;transform:rotate(0deg);}'
2931        + '.com-pane-findreplace.expanded .com-pane-fr-expander .chev{transform:rotate(90deg);}'
2932        + '.com-pane-fr-field{display:flex;height:20px;border-radius:3px;background:var(--com-pane-fr-field-bg,#fff);overflow:hidden;}'
2933        + '.com-pane-fr-field input{min-width:0;flex:1;padding:0 2px;border:0;outline:0;font:10px/1 sans-serif;overflow:hidden;color:var(--com-pane-fr-field-text,#5f5f5f);background:var(--com-pane-fr-field-bg,#fff);}'        
2934        + '.com-pane-fr-field-buttons{display:flex;height:100%;align-items:center;padding-right:8px;gap:2px;}'
2935        + '.com-pane-fr-under{display:inline-flex;align-items:center;justify-content:center;line-height:1;text-decoration:underline;text-underline-offset:3px;}'
2936        + '.com-pane-fr-results{font-size:10px;min-width:70px;white-space:nowrap;}'
2937        + '.com-pane-fr-nav,.com-pane-fr-close,.com-pane-fr-selection-btn{font-size:14px;}'
2938        + '.com-pane-fr-replace-spacer{grid-column:1;}'
2939        + '.com-pane-fr-replace-field{grid-column:2;grid-row:2;}'
2940        + '.com-pane-fr-actions{grid-column:3 / span 3;grid-row:2;display:flex;align-items:center;gap:10px;}'
2941        + '.com-pane-fr-floating{position:fixed;left:10px;top:10px;z-index:3000;display:none;align-items:center;gap:4px;'
2942            + 'padding:4px;border:1px solid var(--com-pane-fr-border,#d2d2d2);border-radius:6px;'
2943            + 'background:var(--com-pane-fr-bg,#f3f3f3);box-shadow:0 2px 12px rgba(0,0,0,.22);color:var(--com-pane-fr-text,#5f5f5f);}'
2944        + '.com-pane-fr-floating.open{display:flex;}'
2945        + '.com-pane-fr-floating .com-pane-fr-btn{flex:0 0 24px;width:24px;min-width:24px;height:24px;}'
2946        + '.com-pane-fr-hidden-row{display:none;}'
2947        + '.com-pane-findreplace.expanded .com-pane-fr-hidden-row{display:flex;}';
2948    };
2949
2950    this.injectFindReplaceCSS = function()
2951    {
2952        this.injectPaneCSS();
2953        if(document.getElementById("com_pane_findreplace_css")) return;
2954        var style = document.createElement("style");
2955        style.id = "com_pane_findreplace_css";
2956        style.textContent = this.findReplaceCSS();
2957        document.head.appendChild(style);
2958    };
2959
2960    this.findReplaceHTML = function(id, cfg)
2961    {
2962        cfg = cfg || {};
2963        var w = cfg.barWidth || "400px";
2964        return ''
2965        + '<div id="' + this.escapeHTML(id) + '" style="width:' + this.escapeHTML(w) + '" class="com-pane-findreplace" aria-label="Find and replace">'
2966        +   '<button type="button" class="com-pane-fr-expander" data-fr-act="expand" title="Expand / collapse replace"><span class="chev">›</span></button>'
2967        +   '<div class="com-pane-fr-field">'
2968        +     '<input class="com-pane-fr-find" placeholder="Find" autocomplete="off" spellcheck="false">'
2969        +     '<span class="com-pane-fr-field-buttons">'
2970        +       '<button type="button" class="com-pane-fr-btn com-pane-fr-case active" title="Match case">Aa</button>'
2971        +       '<button type="button" class="com-pane-fr-btn com-pane-fr-word" title="Match whole word"><span class="com-pane-fr-under">ab</span></button>'
2972        +       '<button type="button" class="com-pane-fr-btn com-pane-fr-regex" title="Use regular expression">.*</button>'
2973        +     '</span>'
2974        +   '</div>'
2975        +   '<div class="com-pane-fr-results">No results</div>'
2976        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-nav com-pane-fr-prev" title="Previous match">↑</button>'
2977        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-nav com-pane-fr-next" title="Next match">↓</button>'
2978        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-selection-btn" title="Find in selection">☰</button>'
2979        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-close" title="Close">×</button>'
2980        +   '<div class="com-pane-fr-replace-spacer com-pane-fr-hidden-row"></div>'
2981        +   '<div class="com-pane-fr-field com-pane-fr-replace-field com-pane-fr-hidden-row">'
2982        +     '<input class="com-pane-fr-replace" placeholder="Replace" autocomplete="off" spellcheck="false">'
2983        +     '<span class="com-pane-fr-field-buttons"><button type="button" class="com-pane-fr-btn com-pane-fr-preserve" title="Preserve case">AB</button></span>'
2984        +   '</div>'
2985        +   '<div class="com-pane-fr-actions com-pane-fr-hidden-row">'
2986        +     '<button type="button" class="com-pane-fr-btn com-pane-fr-replace-one" title="Replace selected match">c</button>'
2987        +     '<button type="button" class="com-pane-fr-btn com-pane-fr-replace-all" title="Replace all">ac</button>'
2988        +   '</div>'
2989        + '</div>';
2990    };
2991
2992    this.findReplaceFloatingNavHTML = function(id)
2993    {
2994        return ''
2995        + '<div id="' + this.escapeHTML(id) + '" class="com-pane-fr-floating" aria-label="Find navigation">'
2996        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-nav com-pane-fr-float-prev" title="Previous match">↑</button>'
2997        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-nav com-pane-fr-float-next" title="Next match">↓</button>'
2998        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-float-top" title="Back to top"><i class="fa fa-angle-double-up"></i></button>'
2999        +   '<button type="button" class="com-pane-fr-btn com-pane-fr-close com-pane-fr-float-close" title="Close">×</button>'
3000        + '</div>';
3001    };
3002
3003    this.bindFindReplace = function(target, cfg)
3004    {
3005        cfg = cfg || {};
3006        this.injectFindReplaceCSS();
3007
3008        if(typeof target == "string") target = document.getElementById(target);
3009        if(!target || target.tagName.toUpperCase() != "TEXTAREA") return null;
3010
3011        var uid = cfg.id || ("com_pane_findreplace_" + (target.id || Math.random().toString(36).slice(2)));
3012        var bar = document.getElementById(uid);
3013
3014        if(!bar) {
3015            var mount = cfg.mount;
3016            if(typeof mount == "string") mount = document.getElementById(mount);
3017            if(mount) mount.insertAdjacentHTML("beforeend", this.findReplaceHTML(uid, cfg));
3018            else document.body.insertAdjacentHTML("beforeend", this.findReplaceHTML(uid, cfg));        
3019            bar = document.getElementById(uid);
3020        }
3021
3022        if(cfg.inline !== false) bar.classList.add("com-pane-fr-inline");
3023        if(cfg.overlayExpand) bar.classList.add("com-pane-fr-overlay-expand");
3024
3025        var floatingNav = null;
3026        if(cfg.floatingNav) {
3027            var floatingId = uid + "_floating";
3028            floatingNav = document.getElementById(floatingId);
3029            if(!floatingNav) {
3030                document.body.insertAdjacentHTML("beforeend", this.findReplaceFloatingNavHTML(floatingId));
3031                floatingNav = document.getElementById(floatingId);
3032            }
3033        }
3034
3035        if(cfg.scheme) {
3036            for(var k in cfg.scheme) {
3037                bar.style.setProperty("--com-pane-fr-" + k, cfg.scheme[k]);
3038                if(floatingNav) floatingNav.style.setProperty("--com-pane-fr-" + k, cfg.scheme[k]);
3039            }
3040        }
3041        if(cfg.manager && typeof cfg.manager.add == "function")
3042            cfg.manager.add(uid, bar, false);
3043
3044        var wrap = target.parentNode;
3045        var overlay;
3046        if(!wrap.classList || !wrap.classList.contains("com-pane-fr-wrap")) {
3047            wrap = document.createElement("div");
3048            wrap.className = "com-pane-fr-wrap";
3049            target.parentNode.insertBefore(wrap, target);
3050            overlay = document.createElement("pre");
3051            overlay.className = "com-pane-fr-overlay";
3052            overlay.setAttribute("aria-hidden", "true");
3053            wrap.appendChild(overlay);
3054            wrap.appendChild(target);
3055        } else {
3056            overlay = wrap.querySelector(".com-pane-fr-overlay");
3057        }
3058
3059        var findInput    = bar.querySelector(".com-pane-fr-find");
3060        var replaceInput = bar.querySelector(".com-pane-fr-replace");
3061        var resultInfo   = bar.querySelector(".com-pane-fr-results");
3062        var prevBtn      = bar.querySelector(".com-pane-fr-prev");
3063        var nextBtn      = bar.querySelector(".com-pane-fr-next");
3064        var caseBtn      = bar.querySelector(".com-pane-fr-case");
3065        var wordBtn      = bar.querySelector(".com-pane-fr-word");
3066        var regexBtn     = bar.querySelector(".com-pane-fr-regex");
3067        var preserveBtn  = bar.querySelector(".com-pane-fr-preserve");
3068        var selectionBtn = bar.querySelector(".com-pane-fr-selection-btn");
3069        var floatPrevBtn = floatingNav ? floatingNav.querySelector(".com-pane-fr-float-prev") : null;
3070        var floatNextBtn = floatingNav ? floatingNav.querySelector(".com-pane-fr-float-next") : null;
3071        var floatTopBtn  = floatingNav ? floatingNav.querySelector(".com-pane-fr-float-top") : null;
3072        var floatCloseBtn = floatingNav ? floatingNav.querySelector(".com-pane-fr-float-close") : null;
3073
3074        var state = {
3075            matches: [],
3076            current: -1,
3077            selectionRange: null,
3078            lastEditorSelection: null,
3079            timer: null,
3080            debounce_ms: cfg.debounce_ms == null ? 500 : cfg.debounce_ms
3081        };
3082
3083        function esc(s) {
3084            return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
3085        }
3086
3087        function renderOverlay() {
3088            var text = target.value;
3089            var ranges = [];
3090            var visibleSelection = state.selectionRange || state.lastEditorSelection;
3091            if(visibleSelection && visibleSelection.end > visibleSelection.start)
3092                ranges.push({start:visibleSelection.start,end:visibleSelection.end,cls:"com-pane-fr-selection",priority:1});
3093
3094            for(var i=0;i<state.matches.length;i++)
3095                ranges.push({start:state.matches[i].start,end:state.matches[i].end,cls:i==state.current?"com-pane-fr-current":"com-pane-fr-match",priority:i==state.current?3:2});
3096
3097            ranges = ranges.filter(function(r){return r.end>r.start}).sort(function(a,b){return a.start-b.start || b.priority-a.priority});
3098            var out = "", pos = 0;
3099            for(var r=0;r<ranges.length;r++) {
3100                if(ranges[r].end <= pos) continue;
3101                var start = Math.max(ranges[r].start, pos);
3102                if(start > pos) out += esc(text.slice(pos,start));
3103                out += '<span class="'+ranges[r].cls+'">'+esc(text.slice(start,ranges[r].end))+'</span>';
3104                pos = ranges[r].end;
3105            }
3106            if(pos < text.length) out += esc(text.slice(pos));
3107            overlay.innerHTML = out || " ";
3108            syncOverlayScroll();
3109        }
3110
3111        function syncOverlayScroll() {
3112            overlay.scrollTop = target.scrollTop;
3113            overlay.scrollLeft = target.scrollLeft;
3114        }
3115
3116        function rememberEditorSelection() {
3117            if(target.selectionStart !== target.selectionEnd)
3118                state.lastEditorSelection = {start:target.selectionStart,end:target.selectionEnd};
3119            updateSelectionButtonState();
3120        }
3121
3122        function hasUsableEditorSelection() {
3123            return !!(state.lastEditorSelection && state.lastEditorSelection.end > state.lastEditorSelection.start);
3124        }
3125
3126        function updateSelectionButtonState() {
3127            var active = selectionBtn.classList.contains("active");
3128            selectionBtn.disabled = !active && !hasUsableEditorSelection();
3129        }
3130
3131
3132        function updateNavButtons()
3133        {
3134            var enabled = state.matches.length > 0;
3135            prevBtn.disabled = !enabled;
3136            nextBtn.disabled = !enabled;
3137            if(floatPrevBtn) floatPrevBtn.disabled = !enabled;
3138            if(floatNextBtn) floatNextBtn.disabled = !enabled;
3139        }
3140
3141        function openSearch(expanded) {
3142            var expandedState = !!expanded;
3143            rememberEditorSelection();
3144            if(cfg.manager && typeof cfg.manager.activate == "function") cfg.manager.activate(uid);
3145            bar.classList.add("open");
3146            bar.classList.toggle("expanded", expandedState);
3147            if(floatingNav) floatingNav.classList.add("open");
3148            setTimeout(function(){ findInput.focus(); findInput.select(); }, 0);
3149            scheduleFind();
3150        }
3151
3152        function closeSearch() 
3153        {
3154            bar.classList.remove("open");
3155            bar.classList.remove("expanded");
3156            if(floatingNav) floatingNav.classList.remove("open");
3157            if(cfg.manager && typeof cfg.manager.activateDefault == "function") cfg.manager.activateDefault();
3158            target.focus();
3159        }
3160
3161        function toggleSearch(expanded) {
3162            if(bar.classList.contains("open")) closeSearch();
3163            else openSearch(expanded);
3164        }        
3165
3166        function escapeRegex(s) {
3167            return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3168        }
3169
3170        function flags(global) {
3171            return (global ? "g" : "") + (caseBtn.classList.contains("active") ? "" : "i");
3172        }
3173
3174        function buildRegex(global) {
3175            var q = findInput.value;
3176            if(!q) return null;
3177            if(!regexBtn.classList.contains("active")) q = escapeRegex(q);
3178            if(wordBtn.classList.contains("active")) q = "\\b(?:" + q + ")\\b";
3179            try { return new RegExp(q, flags(global)); }
3180            catch(e) { return null; }
3181        }
3182
3183        function findScope() {
3184            var txt = target.value;
3185            if(selectionBtn.classList.contains("active") && state.selectionRange)
3186                return {text:txt.slice(state.selectionRange.start,state.selectionRange.end),offset:state.selectionRange.start};
3187            return {text:txt,offset:0};
3188        }
3189
3190        function scheduleFind() {
3191            clearTimeout(state.timer);
3192            state.matches = [];
3193            state.current = -1;
3194            resultInfo.textContent = findInput.value ? "..." : "No results";
3195            updateNavButtons();
3196            renderOverlay();
3197            state.timer = setTimeout(function(){ updateMatches({selectFirst:true, keepFindFocus:true}); }, state.debounce_ms);
3198        }
3199
3200        function updateMatches(options) {
3201            options = options || {};
3202            var re = buildRegex(true), scope = findScope();
3203            state.matches = [];
3204            state.current = -1;
3205
3206            if(re) {
3207                var m;
3208                while((m = re.exec(scope.text)) !== null) {
3209                    state.matches.push({start:scope.offset + m.index,end:scope.offset + m.index + m[0].length,text:m[0]});
3210                    if(m[0].length === 0) re.lastIndex++;
3211                }
3212            }
3213
3214            if(state.matches.length) state.current = 0;
3215            resultInfo.textContent = state.matches.length ? "1 of " + state.matches.length : "No results";
3216            updateNavButtons();
3217
3218            if(state.matches.length && options.selectFirst) selectCurrent({keepFindFocus:!!options.keepFindFocus});
3219            else renderOverlay();
3220        }
3221
3222        function scrollCurrentMatchIntoPage() {
3223            if(!cfg.pageScrollOnMatch) return;
3224
3225            requestAnimationFrame(function() {
3226                var current = overlay ? overlay.querySelector(".com-pane-fr-current") : null;
3227                if(current && typeof current.getBoundingClientRect == "function") {
3228                    var rect = current.getBoundingClientRect();
3229                    var currentY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
3230                    var targetY = Math.max(0, currentY + rect.top - (cfg.pageScrollOffset == null ? 72 : cfg.pageScrollOffset));
3231                    try { window.scrollTo({ top: targetY, left: 0, behavior: cfg.pageScrollBehavior || "smooth" }); }
3232                    catch (_ignore) { window.scrollTo(0, targetY); }
3233                }
3234            });
3235        }
3236
3237        function selectCurrent(mode) {
3238            if(state.current < 0 || !state.matches[state.current]) { renderOverlay(); return; }
3239            mode = mode || {};
3240            var m = state.matches[state.current];
3241            target.setSelectionRange(m.start, m.end);
3242            resultInfo.textContent = (state.current + 1) + " of " + state.matches.length;
3243            renderOverlay();
3244            scrollCurrentMatchIntoPage();
3245            if(mode.focusEditor) target.focus();
3246            else if(mode.keepFindFocus) findInput.focus();
3247        }
3248
3249        function nextMatch(dir, selectMode) {
3250            clearTimeout(state.timer);
3251            if(!state.matches.length) { updateNavButtons(); renderOverlay(); return; }
3252            state.current = (state.current + dir + state.matches.length) % state.matches.length;
3253            selectCurrent(selectMode || {keepFindFocus:true});
3254        }
3255
3256        function preserveCase(replacement, found) {
3257            if(!preserveBtn.classList.contains("active")) return replacement;
3258            if(found.toUpperCase() === found) return replacement.toUpperCase();
3259            if(found.toLowerCase() === found) return replacement.toLowerCase();
3260            if(found[0] && found[0].toUpperCase() === found[0])
3261                return replacement.charAt(0).toUpperCase() + replacement.slice(1).toLowerCase();
3262            return replacement;
3263        }
3264
3265        function regexReplacementText(template, found, groups) {
3266            if(!regexBtn.classList.contains("active")) return template;
3267            return String(template).replace(/\\(n|r|t|\\)|\$(\$|&|`|'|\d{1,2})/g, function(token, esc, dollar) {
3268                if(esc === "n") return "\n";
3269                if(esc === "r") return "\r";
3270                if(esc === "t") return "\t";
3271                if(esc === "\\") return "\\";
3272                if(dollar === "$") return "$";
3273                if(dollar === "&") return found;
3274                if(/^\d+$/.test(dollar)) return groups[Number(dollar)-1] || "";
3275                if(dollar === "`" || dollar === "'") return "";
3276                return token;
3277            });
3278        }
3279
3280        function replacementForMatch(template, found, groups) {
3281            return preserveCase(regexReplacementText(template, found, groups || []), found);
3282        }
3283
3284        function replaceCurrent() {
3285            clearTimeout(state.timer);
3286            if(state.current < 0 || !state.matches[state.current]) return;
3287            var m = state.matches[state.current];
3288            var singleRe = buildRegex(false);
3289            var singleMatch = singleRe ? singleRe.exec(m.text) : null;
3290            var groups = singleMatch ? Array.prototype.slice.call(singleMatch, 1) : [];
3291            var rep = replacementForMatch(replaceInput.value, m.text, groups);
3292            target.value = target.value.slice(0,m.start) + rep + target.value.slice(m.end);
3293
3294            if(state.selectionRange && m.start >= state.selectionRange.start && m.end <= state.selectionRange.end) {
3295                state.selectionRange.end += rep.length - (m.end - m.start);
3296                state.lastEditorSelection = {start:state.selectionRange.start,end:state.selectionRange.end};
3297            }
3298            if(typeof cfg.onChange == "function") cfg.onChange(target);
3299            updateMatches({selectFirst:true, keepFindFocus:true});
3300        }
3301
3302        function replaceAll() {
3303            clearTimeout(state.timer);
3304            var re = buildRegex(true);
3305            if(!re) return;
3306            var scope = findScope();
3307            var replaced = scope.text.replace(re, function() {
3308                var found = arguments[0];
3309                var groups = Array.prototype.slice.call(arguments, 1, -2);
3310                return replacementForMatch(replaceInput.value, found, groups);
3311            });
3312            target.value = target.value.slice(0,scope.offset) + replaced + target.value.slice(scope.offset + scope.text.length);
3313            if(state.selectionRange) {
3314                state.selectionRange.end = state.selectionRange.start + replaced.length;
3315                state.lastEditorSelection = {start:state.selectionRange.start,end:state.selectionRange.end};
3316            }
3317            if(typeof cfg.onChange == "function") cfg.onChange(target);
3318            updateMatches({selectFirst:true, keepFindFocus:true});
3319        }
3320
3321        document.addEventListener("keydown", function(ev) 
3322        {
3323            var mod = ev.ctrlKey || ev.metaKey;
3324            if((document.activeElement === target || document.activeElement === findInput || document.activeElement === replaceInput) && mod && ev.key.toLowerCase() === "f") {
3325                ev.preventDefault(); toggleSearch(false);
3326            }
3327            if((document.activeElement === target || document.activeElement === findInput || document.activeElement === replaceInput) && mod && ev.key.toLowerCase() === "h") {
3328                ev.preventDefault(); toggleSearch(true);
3329            }
3330            if(ev.key === "Escape" && bar.classList.contains("open")) {
3331                ev.preventDefault(); closeSearch();
3332            }
3333        });
3334
3335        target.addEventListener("mousedown", function() 
3336        {
3337            if(!selectionBtn.classList.contains("active")) {
3338                state.lastEditorSelection = null;
3339                renderOverlay();
3340                updateSelectionButtonState();
3341            }
3342        });
3343
3344        ["mouseup","keyup","select"].forEach(function(name) 
3345        {
3346            target.addEventListener(name, function(){ rememberEditorSelection(); renderOverlay(); });
3347        });
3348
3349        target.addEventListener("input", function() 
3350        {
3351            rememberEditorSelection();
3352            if(selectionBtn.classList.contains("active")) {
3353                selectionBtn.classList.remove("active");
3354                state.selectionRange = null;
3355            }
3356            if(typeof cfg.onChange == "function") cfg.onChange(target);
3357            scheduleFind();
3358        });
3359
3360        target.addEventListener("scroll", syncOverlayScroll);
3361        findInput.addEventListener("input", scheduleFind);
3362        findInput.addEventListener("keydown", function(ev) 
3363        {
3364            if(ev.key === "Enter") {
3365                ev.preventDefault();
3366                clearTimeout(state.timer);
3367                if(!state.matches.length) updateMatches({selectFirst:true, keepFindFocus:true});
3368                else nextMatch(ev.shiftKey ? -1 : 1);
3369            }
3370        });
3371        replaceInput.addEventListener("keydown", function(ev) {
3372            if(ev.key === "Enter") { ev.preventDefault(); replaceCurrent(); }
3373        });
3374
3375        bar.querySelector('[data-fr-act="expand"]').onclick = function(){ bar.classList.toggle("expanded"); };
3376        prevBtn.onclick = function(){ nextMatch(-1); };
3377        nextBtn.onclick = function(){ nextMatch(1); };
3378        bar.querySelector(".com-pane-fr-replace-one").onclick = replaceCurrent;
3379        bar.querySelector(".com-pane-fr-replace-all").onclick = replaceAll;
3380        bar.querySelector(".com-pane-fr-close").onclick = closeSearch;
3381        if(floatPrevBtn) floatPrevBtn.onclick = function(ev)
3382        {
3383            if(ev) ev.preventDefault();
3384            nextMatch(-1, {});
3385        };
3386        if(floatNextBtn) floatNextBtn.onclick = function(ev){
3387            if(ev) ev.preventDefault();
3388            nextMatch(1, {});
3389        };
3390        if(floatTopBtn) floatTopBtn.onclick = function(ev){
3391            if(ev) ev.preventDefault();
3392            try { window.scrollTo({ top: 0, left: 0, behavior: "smooth" }); }
3393            catch (_ignore) { window.scrollTo(0, 0); }
3394        };
3395        if(floatCloseBtn) floatCloseBtn.onclick = function(ev){
3396            if(ev) ev.preventDefault();
3397            closeSearch();
3398        };
3399        caseBtn.onclick = function(){ caseBtn.classList.toggle("active"); scheduleFind(); };
3400        wordBtn.onclick = function(){ wordBtn.classList.toggle("active"); scheduleFind(); };
3401        regexBtn.onclick = function(){ regexBtn.classList.toggle("active"); scheduleFind(); };
3402        preserveBtn.onclick = function(){ preserveBtn.classList.toggle("active"); };
3403        selectionBtn.onclick = function() 
3404        {
3405            if(selectionBtn.disabled) return;
3406            if(selectionBtn.classList.contains("active")) {
3407                selectionBtn.classList.remove("active");
3408                state.selectionRange = null;
3409                scheduleFind();
3410                return;
3411            }
3412            if(!hasUsableEditorSelection()) return;
3413            state.selectionRange = {start:state.lastEditorSelection.start,end:state.lastEditorSelection.end};
3414            selectionBtn.classList.add("active");
3415            updateSelectionButtonState();
3416            scheduleFind();
3417            findInput.focus();
3418        };
3419
3420        renderOverlay();
3421        updateSelectionButtonState();
3422        updateNavButtons();
3423        return { open:openSearch, close:closeSearch, toggle:toggleSearch, update:updateMatches, render:renderOverlay, floatingNav:floatingNav };
3424    };
3425
3426    this.createPaneHeaderWidgetManager = function(host, cfg)
3427    {
3428        cfg = cfg || {};
3429        if(typeof host == "string") host = document.getElementById(host);
3430        if(!host) return null;
3431
3432        host.classList.add("com-pane-header-widget-host");
3433
3434        var widgets = {};
3435        var activeId = "";
3436        var defaultId = cfg.defaultId || "default";
3437
3438        function setVisible(id)
3439        {
3440            activeId = id;
3441            for(var k in widgets) {
3442                if(widgets[k])
3443                    widgets[k].classList.toggle("active", k == id);
3444            }
3445            if(typeof cfg.onChange == "function") cfg.onChange(id);
3446        }
3447
3448        function add(id, el, isDefault)
3449        {
3450            if(typeof el == "string") {
3451                host.insertAdjacentHTML("beforeend", el);
3452                el = host.lastElementChild;
3453            }
3454            if(!el) return null;
3455
3456            el.classList.add("com-pane-header-widget");
3457            if(isDefault) {
3458                defaultId = id;
3459                el.classList.add("com-pane-header-default-widget");
3460            }
3461            widgets[id] = el;
3462            if(!el.parentNode) host.appendChild(el);
3463
3464            /*
3465             * If a non-default widget is registered after the default widget
3466             * has already been activated, keep it hidden until activate(id)
3467             * is called.  This is the one-line header toggle contract:
3468             * exactly one registered widget is visible at a time.
3469             */
3470            el.classList.toggle("active", activeId == id);
3471
3472            return el;
3473        }
3474
3475        function activate(id)
3476        {
3477            if(!widgets[id]) return;
3478            setVisible(id);
3479        }
3480
3481        function activateDefault()
3482        {
3483            if(widgets[defaultId]) setVisible(defaultId);
3484        }
3485
3486        function current()
3487        {
3488            return activeId;
3489        }
3490
3491        var api = {
3492            host:host,
3493            widgets:widgets,
3494            add:add,
3495            activate:activate,
3496            activateDefault:activateDefault,
3497            current:current
3498        };
3499
3500        if(cfg.defaultHTML) add(defaultId, cfg.defaultHTML, true);
3501        activateDefault();
3502        return api;
3503    };
3504
3505    this.equalizePaneHeaderHeights = function(selector)
3506    {
3507        selector = selector || ".com-pane-title";
3508        requestAnimationFrame(function() {
3509            var headers = Array.prototype.slice.call(document.querySelectorAll(selector));
3510            var h = 0;
3511            headers.forEach(function(el) {
3512                el.classList.remove("com-pane-title-equalized");
3513                el.style.removeProperty("height");
3514                h = Math.max(h, Math.ceil(el.getBoundingClientRect().height));
3515            });
3516            if(h <= 0) return;
3517            document.documentElement.style.setProperty("--com-pane-title-height", h + "px");
3518            headers.forEach(function(el) { el.classList.add("com-pane-title-equalized"); });
3519        });
3520    };
3521
3522
3523    this.bindSearch = function(wrapper, target, callback)
3524    {
3525      return this.bindFindReplace(target, { onChange:callback });
3526    };
3527
3528    this.escapeHTML = function(str) 
3529    {
3530      return String(str)
3531        .replaceAll("&", "&amp;")
3532        .replaceAll("<", "&lt;")
3533        .replaceAll(">", "&gt;")
3534        .replaceAll("&lt;b&gt;", "<b>")
3535        .replaceAll("&lt;/b&gt;", "</b>")
3536        .replaceAll("&lt;u&gt;", "<u>")
3537        .replaceAll("&lt;/u&gt;", "</u>")
3538        .replaceAll("&lt;i&gt;", "<i>")
3539        .replaceAll("&lt;/i&gt;", "</i>")
3540        .replaceAll('"', "&quot;")
3541        .replaceAll("'", "&#039;")
3542        .replaceAll("\n","<br>")
3543        .replaceAll(" ", "&nbsp;");
3544    }
3545}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.