Skip to content

Commit

Permalink
Improve Terminal keystroke and size handling (#2878)
Browse files Browse the repository at this point in the history
* Improve Terminal keystroke and size handling

* Cleanup
  • Loading branch information
philippjfr authored Nov 3, 2021
1 parent d13e5f6 commit 80d1112
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 56 deletions.
31 changes: 22 additions & 9 deletions panel/models/terminal.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
from collections import OrderedDict

from bokeh.core.properties import Any, Dict, Int, String
from bokeh.events import ModelEvent
from bokeh.models import HTMLBox

from ..io.resources import bundled_files
from ..util import classproperty


XTERM_JS = "https://unpkg.com/xterm@4.11.0/lib/xterm.js"
XTERM_JS = "https://unpkg.com/xterm@4.14.1/lib/xterm.js"
XTERM_LINKS_JS = "https://unpkg.com/xterm-addon-web-links@0.4.0/lib/xterm-addon-web-links.js"


class Terminal(HTMLBox):
"""Custom Terminal Model"""
class KeystrokeEvent(ModelEvent):

options = Dict(String, Any)
input = String()
output = String()
event_name = 'keystroke'

_clears = Int()
_value_repeats = Int()
def __init__(self, model, key=None):
self.key = key
super().__init__(model=model)


class Terminal(HTMLBox):
"""Custom Terminal Model"""

__css_raw__ = ["https://unpkg.com/xterm@4.11.0/css/xterm.css"]

Expand Down Expand Up @@ -52,6 +55,16 @@ def __js_skip__(cls):
'xtermjsweblinks': {
'exports': 'WebLinksAddon',
'deps': ['xtermjs']
}
},
}
}

_clears = Int()

nrows = Int()

ncols = Int()

options = Dict(String, Any)

output = String()
44 changes: 26 additions & 18 deletions panel/models/terminal.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { HTMLBox } from "@bokehjs/models/layouts/html_box"
import * as p from "@bokehjs/core/properties"
import { div } from "@bokehjs/core/dom";
import { div } from "@bokehjs/core/dom"
import {ModelEvent, JSON} from "@bokehjs/core/bokeh_events"

import { PanelHTMLBoxView, set_size } from "./layout"


export class KeystrokeEvent extends ModelEvent {
event_name: string = "keystroke"

constructor(readonly key: string) {
super()
}

protected _to_json(): JSON {
return {model: this.origin, key: this.key}
}
}

export class TerminalView extends PanelHTMLBoxView {
model: Terminal
term: any // Element
fitAddon: any
webLinksAddon: any
container: HTMLDivElement
_rendered: boolean
Expand All @@ -30,7 +42,7 @@ export class TerminalView extends PanelHTMLBoxView {
});

this.webLinksAddon = this.getNewWebLinksAddon()
this.term.loadAddon(this.webLinksAddon);
this.term.loadAddon(this.webLinksAddon)

this.term.open(this.container)

Expand Down Expand Up @@ -58,11 +70,7 @@ export class TerminalView extends PanelHTMLBoxView {
}

handleOnData(value: string): void {
// Hack to handle repeating keyboard inputs
if (this.model.input === value)
this.model._value_repeats+=1
else
this.model.input = value;
this.model.trigger_event(new KeystrokeEvent(value))
}

write(): void {
Expand All @@ -89,10 +97,10 @@ export class TerminalView extends PanelHTMLBoxView {
return
const cols = Math.max(2, Math.floor(width / cell_width))
const rows = Math.max(1, Math.floor(height / cell_height))
if (this.term.rows !== rows || this.term.cols !== cols) {
renderer.clear();
if (this.term.rows !== rows || this.term.cols !== cols)
this.term.resize(cols, rows)
}
this.model.ncols = cols
this.model.nrows = rows
this._rendered = true
}

Expand All @@ -112,9 +120,9 @@ export namespace Terminal {
export type Props = HTMLBox.Props & {
options: p.Property<any>
output: p.Property<string>
input: p.Property<string>
ncols: p.Property<number>
nrows: p.Property<number>
_clears: p.Property<number>
_value_repeats: p.Property<number>
}
}

Expand All @@ -134,11 +142,11 @@ export class Terminal extends HTMLBox {
this.prototype.default_view = TerminalView;

this.define<Terminal.Props>(({Any, Int, String}) => ({
options: [Any, {} ],
output: [String, ],
input: [String, ],
_clears: [Int, 0 ],
_value_repeats: [Int, 0 ],
_clears: [ Int, 0 ],
options: [ Any, {} ],
output: [ String, ],
ncols: [ Int ],
nrows: [ Int ],
}))
}
}
73 changes: 44 additions & 29 deletions panel/widgets/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from pyviz_comms import JupyterComm

from ..io.callbacks import PeriodicCallback
from ..util import lazy_load
from ..util import edit_readonly, lazy_load
from .base import Widget


Expand Down Expand Up @@ -113,23 +113,32 @@ def run(self, *args, **kwargs):
self._child_pid = child_pid
self._fd = fd

# Todo: Determine if it is better to run this is seperate thread?
self._set_winsize()

self._periodic_callback = PeriodicCallback(
callback=self._forward_subprocess_output_to_terminal,
period=self._period,
period=self._period
)
self._periodic_callback.start()

self._watcher = self._terminal.param.watch(
self._forward_terminal_input_to_subprocess, "value"
self._forward_terminal_input_to_subprocess, 'value',
onlychanged=False
)
with param.edit_constant(self):
self.running = True

@param.depends('_terminal.ncols', '_terminal.nrows', watch=True)
def _set_winsize(self):
if self._fd is None:
return
import termios
import struct
import fcntl
winsize = struct.pack("HHHH", self._terminal.nrows, self._terminal.ncols, 0, 0)
fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize)

def _kill(self, *events):
"""
Kills the subprocess.
"""
child_pid = self._child_pid
self._reset()

Expand All @@ -155,15 +164,17 @@ def _remove_last_line_from_string(value):
return value[: value.rfind("CompletedProcess")]

def _forward_subprocess_output_to_terminal(self):
if self._fd:
(data_ready, _, _) = select.select([self._fd], [], [], self._timeout_sec)
if data_ready:
output = os.read(self._fd, self._max_read_bytes).decode()
# If Child Process finished it will signal this by appending "CompletedProcess(...)"
if "CompletedProcess" in output:
self._reset()
output = self._remove_last_line_from_string(output)
self._terminal.write(output)
if not self._fd:
return
(data_ready, _, _) = select.select([self._fd], [], [], self._timeout_sec)
if not data_ready:
return
output = os.read(self._fd, self._max_read_bytes).decode()
# If Child Process finished it will signal this by appending "CompletedProcess(...)"
if "CompletedProcess" in output:
self._reset()
output = self._remove_last_line_from_string(output)
self._terminal.write(output)

def _forward_terminal_input_to_subprocess(self, *events):
if self._fd:
Expand Down Expand Up @@ -203,27 +214,28 @@ class Terminal(Widget):
output = param.String(default="", doc="""
System output written to the Terminal""")

ncols = param.Integer(readonly=True, doc="""
The number of columns in the terminal.""")

nrows = param.Integer(readonly=True, doc="""
The number of rows in the terminal.""")

value = param.String(label="Input", readonly=True, doc="""
User input received from the Terminal. Sent one character at the time.""")

write_to_console = param.Boolean(default=False, doc="""
Weather or not to write to the server console. Default is False""")
Whether or not to write to the server console.""")

_clears = param.Integer(doc="Sends a signal to clear the terminal")

_output = param.String(default="")

_value_repeats = param.Integer(
doc="""
Hack: Sends a signal that the value has been repeated."""
)

_rename = {
"clear": None,
"output": None,
"_output": "output",
"value": "input",
"write_to_console": None,
"value": None
}

def __init__(self, output=None, **params):
Expand All @@ -232,7 +244,7 @@ def __init__(self, output=None, **params):
super().__init__(output=output, **params)
self._subprocess = None

def write(self, __s, ):
def write(self, __s):
cleaned = __s
if isinstance(__s, str):
cleaned = __s
Expand All @@ -241,7 +253,7 @@ def write(self, __s, ):
else:
cleaned = str(__s)

if self.output == cleaned:
if self._output == cleaned:
# Hack to support writing the same string multiple times in a row
self._output = ""

Expand All @@ -256,8 +268,15 @@ def _get_model(self, doc, root=None, parent=None, comm=None):
)
model = super()._get_model(doc, root, parent, comm)
model.output = self.output
model.on_event('keystroke', self._process_event)
return model

def _process_event(self, event):
with edit_readonly(self):
self.value = event.key
with param.discard_events(self):
self.value = ""

def _clear(self, *events):
"""
Clears all output on the terminal.
Expand All @@ -270,10 +289,6 @@ def _write(self):
if self.write_to_console:
sys.__stdout__.write(self._output)

@param.depends("_value_repeats", watch=True)
def _repeat_value_hack(self):
self.param.trigger('value')

def __repr__(self, depth=None):
return f"Terminal(id={id(self)})"

Expand Down

0 comments on commit 80d1112

Please sign in to comment.