/* ** stmux -- Simple Terminal Multiplexing for Node Environments ** Copyright (c) 2017-2026 Dr. Ralf S. Engelschall ** ** Permission is hereby granted, free of charge, to any person obtaining ** a copy of this software and associated documentation files (the ** "Software"), to deal in the Software without restriction, including ** without limitation the rights to use, copy, modify, merge, publish, ** distribute, sublicense, and/or sell copies of the Software, and to ** permit persons to whom the Software is furnished to do so, subject to ** the following conditions: ** ** The above copyright notice and this permission notice shall be included ** in all copies or substantial portions of the Software. ** ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import Blessed from "blessed" import type { Widgets } from "blessed" import type { Constructor, STMUXBase, Border, BorderSide, Terminal } from "./stmux-0-types.js" export default >(Base: T) => class extends Base { override handleKeys (): void { /* switch focus to the terminal with the given index */ const switchFocus = (i: number) => { this.terms[this.focused].resetScroll() this.focused = i this.terms[this.focused].focus() this.screen.render() } /* handle keys */ let prefixMode: "idle" | "prefix" | "post" = "idle" this.screen.on("keypress", (_ch: string, key: Widgets.Events.IKeyEventArg) => { if ((prefixMode === "idle" || prefixMode === "post") && key.full === `C-${this.argv.activator}`) { /* enter prefix mode */ prefixMode = "prefix" this.terms[this.focused].enableInput(false) } else if (prefixMode === "prefix") { /* handle prefix mode */ prefixMode = "post" if (key.full === this.argv.activator) { /* handle special prefix activator character */ const ch = String.fromCharCode(1 + this.argv.activator.charCodeAt(0) - "a".charCodeAt(0)) this.terms[this.focused].injectInput(ch) } else if (this.zoomed === -1 && key.full === "backspace") { /* handle terminal focus change (step-by-step, sequenced) */ switchFocus((this.focused + this.terms.length - 1) % this.terms.length) } else if (this.zoomed === -1 && key.full === "space") { /* handle terminal focus change (step-by-step, sequenced) */ switchFocus((this.focused + 1) % this.terms.length) } else if ( this.zoomed === -1 && ( key.full === "left" || key.full === "right" || key.full === "up" || key.full === "down" )) { /* handle terminal focus change (step-by-step, directional) */ /* determine border of focused terminal where we want to logically break through */ const sides: Record = { left: { leave: "left", enterOn: "right" }, right: { leave: "right", enterOn: "left" }, up: { leave: "top", enterOn: "bottom" }, down: { leave: "bottom", enterOn: "top" } } const enterOn = sides[key.full].enterOn const leave: Border = this.border(this.terms[this.focused], sides[key.full].leave) /* find the touchpoints of terminals with our border */ const touchpoints: { i: number, touches: number }[] = [] for (let i = 0; i < this.terms.length; i++) { if (i === this.focused) touchpoints[i] = { i, touches: 0 } else { const enter = this.border(this.terms[i], enterOn) if ( (enterOn === "left" && enter.x1 === (leave.x1 + 1)) || (enterOn === "right" && enter.x1 === (leave.x1 - 1))) touchpoints[i] = { i, touches: this.touches(leave.y1, leave.y2, enter.y1, enter.y2) } else if ( (enterOn === "top" && enter.y1 === (leave.y1 + 1)) || (enterOn === "bottom" && enter.y1 === (leave.y1 - 1))) touchpoints[i] = { i, touches: this.touches(leave.x1, leave.x2, enter.x1, enter.x2) } else touchpoints[i] = { i, touches: 0 } } } /* determine best matching terminal */ const bestMatch = touchpoints.reduce((best, tp) => tp.touches > best.touches ? tp : best) /* switch to best matching one */ if (bestMatch.touches > 0) switchFocus(bestMatch.i) } else if (this.zoomed === -1 && /^[1-9]$/.test(key.full)) { /* handle terminal focus change (directly) */ const n = parseInt(key.full, 10) if (n <= this.terms.length) switchFocus(n - 1) } else if (key.full === "n") { /* handle number toggling */ this.argv.number = !this.argv.number this.provisionAgain() this.terms[this.focused].focus() this.screen.render() } else if (key.full === "l") { /* handle manual screen redrawing (by forcing Blessed to redraw everything via temporarily opening a dummy box) */ this.provisionAgain() const dummyBox = Blessed.box({ left: 0, top: 0, width: this.screenWidth, height: this.screenHeight, content: "" }) this.screen.append(dummyBox) this.screen.render() this.screen.remove(dummyBox) this.screen.render() } else if (key.full === "z") { /* handle zooming */ this.zoomed = (this.zoomed === -1 ? this.focused : -1) this.provisionAgain() this.terms[this.focused].focus() this.screen.render() } else if (key.full === "e") { /* handle expanding */ this.expanded = !this.expanded this.provisionAgain() this.terms[this.focused].focus() this.screen.render() } else if (key.full === "v") { /* handle scrolling/visual mode */ this.terms[this.focused].scroll(0) } else if (key.full === "r") { /* handle manual restarting */ const term = this.terms[this.focused] if (term.stmuxRestartTimer !== undefined) { /* cancel a still pending automatic restart (the process is already gone, so its "exit" event was already delivered and handled) */ clearTimeout(term.stmuxRestartTimer) term.stmuxRestartTimer = undefined } else if (term.stmuxExited) { /* revoke the termination bookkeeping of the "exit" handler (the process is already gone, so its "exit" event was already delivered and handled) */ term.stmuxExited = false this.terminated-- if (term.stmuxExitCode !== 0) this.terminatedError-- } else { /* the still running process is killed now and delivers a trailing "exit" event which has to be ignored */ term.stmuxIgnoreExits++ } term.terminate() term.spawn(term.stmuxShell, term.stmuxArgs) } else if (key.full === "?") { /* handle help screen toggling */ this.helpBox.show() this.screen.render() } else if (key.full === "k") { /* send CTRL+c to all terminals to give processes a chance to gracefully terminate */ this.terms.forEach((term) => term.injectInput("\x03")) setTimeout(() => { /* terminate all terminal processes */ this.terms.forEach((term) => term.terminate()) setTimeout(() => { /* finally kill the program */ this.terminate() }, 500) }, 500) } } else if (prefixMode === "post") { /* leave prefix mode */ this.terms[this.focused].enableInput(true) prefixMode = "idle" if (this.helpBox.visible) { this.helpBox.hide() this.screen.render() } } }) /* handle mouse */ if (this.argv.mouse) { /* scroll a terminal by 10% into the given direction */ const scrollByWheel = (term: Terminal, direction: 1 | -1) => { /* skip scrolling entirely on an empty scrollback (else the pane would be stuck in scrolling mode, as the position never reaches 100 percent) */ if (term.getScrollHeight() === 0) return /* on-the-fly start scrolling */ if (!term.scrolling) term.scroll(0) /* scroll 10% into the direction */ const n = Math.max(1, Math.floor(term.position.height * 0.10)) term.scroll(direction * n) /* reset/stop scrolling once we reached the end (again) */ if (direction > 0 && Math.ceil(term.getScrollPerc()) === 100) term.resetScroll() } this.terms.forEach((term) => { term.on("wheeldown", () => scrollByWheel(term, +1)) term.on("wheelup", () => scrollByWheel(term, -1)) }) } } }