From b01fc5386b16ddf8840d68606dcc0e7ffbdf7902 Mon Sep 17 00:00:00 2001 From: sbosse Date: Mon, 21 Jul 2025 23:11:41 +0200 Subject: [PATCH] Mon 21 Jul 22:43:21 CEST 2025 --- js/term/widgets/textarea.js | 540 ++++++++++++++++++++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 js/term/widgets/textarea.js diff --git a/js/term/widgets/textarea.js b/js/term/widgets/textarea.js new file mode 100644 index 0000000..6952103 --- /dev/null +++ b/js/term/widgets/textarea.js @@ -0,0 +1,540 @@ +/** + ** ============================== + ** O O O OOOO + ** O O O O O O + ** O O O O O O + ** OOOO OOOO O OOO OOOO + ** O O O O O O O + ** O O O O O O O + ** OOOO OOOO O O OOOO + ** ============================== + ** Dr. Stefan Bosse http://www.bsslab.de + ** + ** COPYRIGHT: THIS SOFTWARE, EXECUTABLE AND SOURCE CODE IS OWNED + ** BY THE AUTHOR(S). + ** THIS SOURCE CODE MAY NOT BE COPIED, EXTRACTED, + ** MODIFIED, OR OTHERWISE USED IN A CONTEXT + ** OUTSIDE OF THE SOFTWARE SYSTEM. + ** + ** $AUTHORS: Christopher Jeffrey, Stefan Bosse + ** $INITIAL: (C) 2013-2018, Christopher Jeffrey and contributors + ** $MODIFIED: by sbosse (2017-2021) + ** $REVESIO: 1.5.2 + ** + ** $INFO: + ** + ** textarea.js - textarea element for blessed + ** + ** new: cursor control + ** + ** special options: {cursorControl} + ** + ** $ENDOFINFO + */ + +/** + * Modules + */ +var Comp = Require('com/compat'); + +var unicode = Require('term/unicode'); + +var nextTick = global.setImmediate || process.nextTick.bind(process); + +var Node = Require('term/widgets/node'); +var Input = Require('term/widgets/input'); + +/** + * Textarea + */ + +function Textarea(options) { + var self = this; + + if (!instanceOf(this,Node)) { + return new Textarea(options); + } + + options = options || {}; + + options.scrollable = options.scrollable !== false; + + Input.call(this, options); + + this.screen._listenKeys(this); + + this.value = options.value || ''; + // cursor position + this.cpos = {x:-1,y:-1}; + this.cursorControl=true; + this.multiline=options.multiline; + + this.__updateCursor = this._updateCursor.bind(this); + this.on('resize', this.__updateCursor); + this.on('move', this.__updateCursor); + + if (options.inputOnFocus) { + this.on('focus', this.readInput.bind(this, null)); + } + + if (!options.inputOnFocus && options.keys) { + this.on('keypress', function(ch, key) { + if (self._reading) return; + if (key.name === 'enter' || (options.vi && key.name === 'i')) { + return self.readInput(); + } + if (key.name === 'e') { + return self.readEditor(); + } + }); + } + + if (options.mouse) { + this.on('click', function(data) { + if (self._reading) return; + if (data.button !== 'right') return; + self.readEditor(); + }); + } +} + +//Textarea.prototype.__proto__ = Input.prototype; +inheritPrototype(Textarea,Input); + +Textarea.prototype.type = 'textarea'; + +Textarea.prototype._updateCursor = function(get) { + if (this.screen.focused !== this) { + return; + } + var lpos = get ? this.lpos : this._getCoords(); + if (!lpos) return; + + var last = this._clines[this._clines.length - 1] + , program = this.screen.program + , line + , offsetY = this.childBase||0 + , cx + , cy; + + // Stop a situation where the textarea begins scrolling + // and the last cline appears to always be empty from the + // _typeScroll `+ '\n'` thing. + // Maybe not necessary anymore? + if (last === '' && this.value[this.value.length - 1] !== '\n') { + last = this._clines[this._clines.length - 2] || ''; + } + + line = Math.min( + this._clines.length - 1 - (this.childBase || 0), + (lpos.yl - lpos.yi) - this.iheight - 1); + + // When calling clearValue() on a full textarea with a border, the first + // argument in the above Math.min call ends up being -2. Make sure we stay + // positive. + line = Math.max(0, line); + if (this.cpos.x==-1 || !this.cursorControl) this.cpos.x = this.strWidth(last); + if (this.cpos.y==-1 || !this.cursorControl) this.cpos.y = line; + this.cpos.y = Math.min(this.cpos.y,line); + this.cpos.x = Math.min(this.cpos.x,this.strWidth(this._clines[offsetY+this.cpos.y])); + + cx = lpos.xi + this.ileft + this.cpos.x; + cy = lpos.yi + this.itop + this.cpos.y; + + // XXX Not sure, but this may still sometimes + // cause problems when leaving editor. + if (cy === program.y && cx === program.x) { + return; + } + + if (cy === program.y) { + if (cx > program.x) { + program.cuf(cx - program.x); + } else if (cx < program.x) { + program.cub(program.x - cx); + } + } else if (cx === program.x) { + if (cy > program.y) { + program.cud(cy - program.y); + } else if (cy < program.y) { + program.cuu(program.y - cy); + } + } else { + program.cup(cy, cx); + } +}; + +Textarea.prototype.input = +Textarea.prototype.setInput = +Textarea.prototype.readInput = function(callback) { + var self = this + , focused = this.screen.focused === this; + + if (this._reading) return; + this._reading = true; + + this._callback = callback; + + if (!focused) { + this.screen.saveFocus(); + this.focus(); + } + + this.screen.grabKeys = true; + + this._updateCursor(); + this.screen.program.showCursor(); + //this.screen.program.sgr('normal'); + this._done = function fn(err, value) { + if (!self._reading) return; + + if (fn.done) return; + fn.done = true; + + + delete self._callback; + delete self._done; + + self.removeListener('keypress', self.__listener); + delete self.__listener; + + self.removeListener('blur', self.__done); + delete self.__done; + + self.screen.program.hideCursor(); + self.screen.grabKeys = false; + + if (!focused) { + self.screen.restoreFocus(); + } + + if (self.options.inputOnFocus) { + self.screen.rewindFocus(); + } + + self._reading = false; + + // Ugly + if (err === 'stop') return; + + if (err) { + self.emit('error', err); + } else if (value != null) { + self.emit('submit', value); + } else { + self.emit('cancel', value); + } + self.emit('action', value); + + if (!callback) return; + + return err + ? callback(err) + : callback(null, value); + }; + + // Put this in a nextTick so the current + // key event doesn't trigger any keys input. + + nextTick(function() { + if (self.__listener) { + // double fired? + return; + } + self.__listener = self._listener.bind(self); + self.on('keypress', self.__listener); + }); + + this.__done = this._done.bind(this, null, null); + this.on('blur', this.__done); +}; + +Textarea.prototype._listener = function(ch, key) { + // Cursor position must be synced with scrollablebox and vice versa (if scrollable)! A real mess. + var done = this._done + , self = this + , value = this.value + , clinesLength=this._clines.length + , offsetY = this.childBase||0 // scrollable line offset if any + , newline = false + , backspace = false + , lastline = (this.cpos.y+offsetY+1) == clinesLength; + + if (key.name == 'linefeed' || + (!this.multiline && key.name=='enter')) return this.emit('enter',this.value); + + if (key.name === 'return') return; + if (key.name === 'enter') { + ch = '\n'; + // this.cpos.x=1; + // this.cpos.y++; + newline=true; + } + + // Handle cursor positiong by keys. + if (this.cursorControl) switch (key.name) { + case 'left': + if (this.cpos.x>0) this.cpos.x--; + else { + if (this.cpos.y>0) { + this.cpos.y--; + this.cpos.x=this._clines[offsetY+this.cpos.y].length; + } else if (offsetY>0) { + if (this.scrollable) this.scroll(-1); + self.screen.render(); + this.cpos.x=this._clines[offsetY+this.cpos.y-1].length; + } + } + this._updateCursor(true); + break; + case 'right': + var next=++this.cpos.x; + this._updateCursor(true); + if (this.cpos.x!=next && (offsetY+this.cpos.y+1)0) { + this.cpos.y--; + //this.cpos.x=this.strWidth(this._clines[this.cpos.y]); + } + this._updateCursor(true); + break; + case 'down': + this.cpos.y++; + this._updateCursor(true); + break; + } + + + if (this.options.keys && key.ctrl && key.name === 'e') { + return this.readEditor(); + } + // Challenge: sync with line wrapping and adjust cursor and scrolling (done in element._wrapContent) + + // TODO: Optimize typing by writing directly + // to the screen and screen buffer here. + if (key.name === 'escape') { + done(null, null); + } else if (key.name === 'backspace') { + backspace=true; + if (this.value.length) { + if (this.screen.fullUnicode) { + if (unicode.isSurrogate(this.value, this.value.length - 2)) { + // || unicode.isCombining(this.value, this.value.length - 1)) { + this.value = this.value.slice(0, -2); + } else { + this.value = this.value.slice(0, -1); + } + } else { + if (!this.cursorControl || + this.cpos.x==-1 || + (this.cpos.x==this._clines[offsetY+this.cpos.y].length && + this.cpos.y==this._clines.length-1-offsetY)) { + // Delete last char of last line + this.value = this.value.slice(0, -1); + } else { + // Delete char at current cursor position + vpos=this.getLinearPos(this.value,offsetY+this.cpos.y, this.cpos.x); + // vpos+= this.cpos.x; + this.value = this.value.substr(0,vpos-1)+ + this.value.substr(vpos,1000000); + } + } + if (this.cpos.x>0) this.cpos.x--; + else {this.cpos.x=-1; if (offsetY==0 && this.cpos.y>0 && lastline) this.cpos.y--; }; + } + } else if (ch) { + if (!/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]$/.test(ch)) { + if (!this.cursorControl || + this.cpos.x==-1 || + (this.cpos.x==this._clines[offsetY+this.cpos.y].length && + this.cpos.y==this._clines.length-1-offsetY)) + // Append new char at end of (last) line + this.value += ch; + else { + // Insert new char into line at current cursor position + vpos=this.getLinearPos(this.value,offsetY+this.cpos.y, this.cpos.x); + // vpos+= this.cpos.x; + this.value = this.value.substr(0,vpos)+ch+ + this.value.substr(vpos,1000000); + } + if (newline) { + this.cpos.x=0; // first left position is zero! + this.cpos.y++; + } else + this.cpos.x++; + } + } + + var rmline=this.cpos.x==-1; + // if (this.childOffset!=undefined) this.childOffset=this.cpos.y; + + // TODO: clean up this mess; use rtof and ftor attributes of _clines + // to determine where we are (react correctly on line wrap extension and reduction) + + if (this.value !== value) { + var cn0=clinesLength, + cn1=this._clines.length, + y0=this.cpos.y, + linelength=this._clines[offsetY+this.cpos.y] && this._clines[offsetY+this.cpos.y].length, + endofline=this.cpos.x==linelength+1; +// Log(this.cpos,this.childBase); + this.screen.render(); + var cn2=this._clines.length; +// Log(this.cpos,lastline,endofline,rmline,newline,backspace,cn0,cn2,this.childBase); + if (!newline && endofline && cn2>cn0) { + // wrap expansion + if (this.scrollable && lastline) this.scrollBottom(); + this.cpos.y++; + this._updateCursor(true); + if (this._clines[offsetY+this.cpos.y]) this.cpos.x=this._clines[offsetY+this.cpos.y].length; + this._updateCursor(true); + } else if (cn20 && !lastline && endofline) this.cpos.y--; + this._updateCursor(true); + offsetY=this.childBase||0; + if (this._clines[offsetY+this.cpos.y]) this.cpos.x=this._clines[offsetY+this.cpos.y].length; + this._updateCursor(true); + } else if (cn20 && backspace) { + // @fix line deleted; refresh again due to miscalculation of height in scrollablebox! + if (this.scrollable) this.scroll(0); + this.screen.render(); + if (rmline && cn0!=cn2) { + if (this._clines[offsetY+this.cpos.y]) this.cpos.x=this._clines[offsetY+this.cpos.y].length; + else if (this._clines[offsetY+this.cpos.y-1]) this.cpos.x=this._clines[offsetY+this.cpos.y-1].length; + this._updateCursor(true); + } + } + } + +}; + +// Return start position of nth (c)line in linear value string +Textarea.prototype.getLinearPos = function(v,clineIndex,cposx) { + // clineIndex is the index in the _clines array, cposx the cursor position in this line! + var vpos=0,len=v.length,cline,clinePos=0,clineNum=0; + cline=this._clines[clineNum]; + // To support auto line wrapping the clines have to be parsed, too! + while (vpos < len && clineIndex) { + if (v.charAt(vpos)=='\n') { + clinePos=-1; + clineIndex--; + clineNum++; + cline=this._clines[clineNum]; + } else { + if (v.charAt(vpos) != cline.charAt(clinePos)) { + // + clinePos=0; + clineIndex--; + clineNum++; + cline=this._clines[clineNum]; + continue; + } + } + vpos++; clinePos++; + } + if (clineIndex==0) return vpos+cposx; + else 0 +} + +Textarea.prototype._typeScroll = function() { + // XXX Workaround + var height = this.height - this.iheight; + // Scroll down? +// if (typeof Log != 'undefined') Log(this.childBase,this.childOffset,this.cpos.y,height); + //if (this._clines.length - this.childBase > height) { + if (this.cpos.y == height) { + if (this.scrollable) this.scroll(this._clines.length); + } +}; + +Textarea.prototype.getValue = function() { + return this.value; +}; + +Textarea.prototype.setValue = function(value) { + if (value == null) { + value = this.value; + } + if (this._value !== value) { + this.value = value; + this._value = value; + this.setContent(this.value); + this._typeScroll(); + this._updateCursor(); + } +}; + +Textarea.prototype.clearInput = +Textarea.prototype.clearValue = function() { + return this.setValue(''); +}; + +Textarea.prototype.submit = function() { + if (!this.__listener) return; + return this.__listener('\x1b', { name: 'escape' }); +}; + +Textarea.prototype.cancel = function() { + if (!this.__listener) return; + return this.__listener('\x1b', { name: 'escape' }); +}; + +Textarea.prototype.render = function() { + this.setValue(); + return this._render(); +}; + +Textarea.prototype.editor = +Textarea.prototype.setEditor = +Textarea.prototype.readEditor = function(callback) { + var self = this; + + if (this._reading) { + var _cb = this._callback + , cb = callback; + + this._done('stop'); + + callback = function(err, value) { + if (_cb) _cb(err, value); + if (cb) cb(err, value); + }; + } + + if (!callback) { + callback = function() {}; + } + + return this.screen.readEditor({ value: this.value }, function(err, value) { + if (err) { + if (err.message === 'Unsuccessful.') { + self.screen.render(); + return self.readInput(callback); + } + self.screen.render(); + self.readInput(callback); + return callback(err); + } + self.setValue(value); + self.screen.render(); + return self.readInput(callback); + }); +}; + +/** + * Expose + */ + +module.exports = Textarea;