jupyterminal 0.0.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jupyterminal/__init__.py +13 -0
- jupyterminal/__main__.py +7 -0
- jupyterminal/app.py +111 -0
- jupyterminal/config.py +253 -0
- jupyterminal/desktop.py +84 -0
- jupyterminal/session/__init__.py +13 -0
- jupyterminal/session/block.py +150 -0
- jupyterminal/session/session.py +1044 -0
- jupyterminal/session/text.py +138 -0
- jupyterminal/shell/__init__.py +7 -0
- jupyterminal/shell/integration.py +157 -0
- jupyterminal/shell/pty.py +203 -0
- jupyterminal/shell/zsh/integration.zsh +433 -0
- jupyterminal/shell/zsh/zlogin.zsh +13 -0
- jupyterminal/shell/zsh/zprofile.zsh +12 -0
- jupyterminal/shell/zsh/zshenv.zsh +87 -0
- jupyterminal/shell/zsh/zshrc.zsh +19 -0
- jupyterminal/term/__init__.py +9 -0
- jupyterminal/term/charwidth.py +69 -0
- jupyterminal/term/keys.py +113 -0
- jupyterminal/term/screen.py +1406 -0
- jupyterminal/term/style.py +226 -0
- jupyterminal/term/terminal.py +716 -0
- jupyterminal/ui/__init__.py +6 -0
- jupyterminal/ui/bars.py +212 -0
- jupyterminal/ui/cell.py +541 -0
- jupyterminal/ui/jupyterminal.svg +12 -0
- jupyterminal/ui/layout.py +358 -0
- jupyterminal/ui/render.py +315 -0
- jupyterminal/ui/theme.py +149 -0
- jupyterminal/ui/view.py +2665 -0
- jupyterminal/ui/window.py +57 -0
- jupyterminal-0.0.1.dev0.dist-info/METADATA +245 -0
- jupyterminal-0.0.1.dev0.dist-info/RECORD +38 -0
- jupyterminal-0.0.1.dev0.dist-info/WHEEL +5 -0
- jupyterminal-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- jupyterminal-0.0.1.dev0.dist-info/licenses/LICENSE +201 -0
- jupyterminal-0.0.1.dev0.dist-info/top_level.txt +1 -0
jupyterminal/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""jupyterminal: a terminal emulator whose session is a list of blocks.
|
|
2
|
+
|
|
3
|
+
A block is one command plus everything it printed, kept as a unit that can
|
|
4
|
+
be selected, copied and navigated to.
|
|
5
|
+
|
|
6
|
+
* :mod:`jupyterminal.term` - terminal emulation (parser, screen model);
|
|
7
|
+
* :mod:`jupyterminal.shell` - the pseudo-terminal and the zsh integration
|
|
8
|
+
that reports where commands start and end;
|
|
9
|
+
* :mod:`jupyterminal.session` - blocks, and the router that fills them;
|
|
10
|
+
* :mod:`jupyterminal.ui` - the Qt user interface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = '0.0.1-dev0'
|
jupyterminal/__main__.py
ADDED
jupyterminal/app.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from . import config as _config
|
|
13
|
+
from . import desktop as _desktop
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="jupyterminal",
|
|
19
|
+
description="A terminal emulator whose session is a list of "
|
|
20
|
+
"command blocks.")
|
|
21
|
+
parser.add_argument("--version", action="version",
|
|
22
|
+
version=f"jupyterminal {__version__}")
|
|
23
|
+
parser.add_argument("--config", metavar="FILE",
|
|
24
|
+
help="configuration file (default: "
|
|
25
|
+
f"{_config.default_path()})")
|
|
26
|
+
parser.add_argument("--shell", metavar="PROGRAM",
|
|
27
|
+
help="shell to run (default: zsh)")
|
|
28
|
+
parser.add_argument("--login", action="store_true",
|
|
29
|
+
help="start the shell as a login shell")
|
|
30
|
+
parser.add_argument("-d", "--working-directory", metavar="DIR",
|
|
31
|
+
help="directory to start in")
|
|
32
|
+
parser.add_argument("--print-default-config", action="store_true",
|
|
33
|
+
help="print a configuration file with every "
|
|
34
|
+
"default and exit")
|
|
35
|
+
parser.add_argument("--install-desktop-entry", action="store_true",
|
|
36
|
+
help="put the program into the menus of the "
|
|
37
|
+
"desktop, which also gives its windows their "
|
|
38
|
+
"icon there, and exit")
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str] | None = None) -> int:
|
|
43
|
+
args = _parser().parse_args(argv)
|
|
44
|
+
if args.print_default_config:
|
|
45
|
+
sys.stdout.write(_config.DEFAULT_CONFIG)
|
|
46
|
+
return 0
|
|
47
|
+
if args.install_desktop_entry:
|
|
48
|
+
written = _desktop.install(_desktop.launcher())
|
|
49
|
+
print(f"jupyterminal: wrote {written}; delete that file to take "
|
|
50
|
+
"the program out of the menus again")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
path = args.config or _config.default_path()
|
|
54
|
+
config = _config.load(path)
|
|
55
|
+
for warning in config.warnings:
|
|
56
|
+
print(f"jupyterminal: {warning}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
# Imported late so that --help and --version need no display.
|
|
59
|
+
from PySide6.QtCore import QMetaObject, QStandardPaths, Qt
|
|
60
|
+
from PySide6.QtGui import QIcon
|
|
61
|
+
from PySide6.QtWidgets import QApplication
|
|
62
|
+
|
|
63
|
+
from .session import Session
|
|
64
|
+
from .shell import find_shell, is_zsh
|
|
65
|
+
from .ui import MainWindow
|
|
66
|
+
|
|
67
|
+
app = QApplication.instance() or QApplication(sys.argv[:1])
|
|
68
|
+
# Being told to stop is a normal way to end: hang up the shell and
|
|
69
|
+
# remove the session's private directory. Python only notices a
|
|
70
|
+
# signal when it gets to run; the view's timer, which looks after the
|
|
71
|
+
# shell several times a second, sees to that. The request is queued,
|
|
72
|
+
# because one that comes before the event loop has started would
|
|
73
|
+
# otherwise be forgotten.
|
|
74
|
+
for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
75
|
+
signal.signal(number, lambda *_: QMetaObject.invokeMethod(
|
|
76
|
+
app, "quit", Qt.ConnectionType.QueuedConnection))
|
|
77
|
+
app.setApplicationName("jupyterminal")
|
|
78
|
+
app.setWindowIcon(QIcon(_desktop.icon_path()))
|
|
79
|
+
# The window belongs to the desktop entry of that name when one is
|
|
80
|
+
# installed. Naming an entry that does not exist only earns a
|
|
81
|
+
# complaint from the desktop ("App info not found") at every start.
|
|
82
|
+
if QStandardPaths.locate(
|
|
83
|
+
QStandardPaths.StandardLocation.ApplicationsLocation,
|
|
84
|
+
"jupyterminal.desktop"):
|
|
85
|
+
app.setDesktopFileName("jupyterminal")
|
|
86
|
+
|
|
87
|
+
shell = find_shell(args.shell or config.shell)
|
|
88
|
+
if shutil.which(shell) is None:
|
|
89
|
+
print(f"jupyterminal: cannot run {shell}: no such program",
|
|
90
|
+
file=sys.stderr)
|
|
91
|
+
return 127
|
|
92
|
+
if not is_zsh(shell):
|
|
93
|
+
print(f"jupyterminal: {shell} is not zsh; without its integration "
|
|
94
|
+
"the whole session is one block.", file=sys.stderr)
|
|
95
|
+
cwd = args.working_directory
|
|
96
|
+
if cwd and not os.path.isdir(cwd):
|
|
97
|
+
# Said, and no more: a shell that cannot enter its directory
|
|
98
|
+
# starts where this program was started.
|
|
99
|
+
print(f"jupyterminal: {cwd}: not a directory", file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
session = Session(100, 30, max_lines=config.scrollback_lines,
|
|
102
|
+
divert=config.divert_background_output)
|
|
103
|
+
window = MainWindow(session, config)
|
|
104
|
+
for warning in window.view.theme.warnings:
|
|
105
|
+
print(f"jupyterminal: {path}: {warning}", file=sys.stderr)
|
|
106
|
+
session.start(shell, cwd=cwd, login=args.login or config.login_shell)
|
|
107
|
+
window.view.attach_io()
|
|
108
|
+
window.show()
|
|
109
|
+
status = app.exec()
|
|
110
|
+
session.close()
|
|
111
|
+
return status
|
jupyterminal/config.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""User configuration.
|
|
2
|
+
|
|
3
|
+
Read from ``$XDG_CONFIG_HOME/jupyterminal/config.toml`` (normally
|
|
4
|
+
``~/.config/jupyterminal/config.toml``). Every key is optional; see
|
|
5
|
+
:data:`DEFAULT_CONFIG` for the full file with its defaults.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
#: action -> default shortcuts. The first group works everywhere, the
|
|
15
|
+
#: ``blocks.*`` group only while a block is selected.
|
|
16
|
+
DEFAULT_KEYS: dict[str, list[str]] = {
|
|
17
|
+
"copy": ["Ctrl+Shift+C"],
|
|
18
|
+
"copy_command_and_output": ["Ctrl+Alt+Shift+C"],
|
|
19
|
+
"paste": ["Ctrl+Shift+V"],
|
|
20
|
+
"paste_selection": ["Shift+Ins"],
|
|
21
|
+
"select_newest_block": ["Ctrl+Up"],
|
|
22
|
+
"clear_blocks": ["Ctrl+Shift+K"],
|
|
23
|
+
"find_in_block": ["Ctrl+Shift+F"],
|
|
24
|
+
"filter_output": ["Ctrl+Shift+L"],
|
|
25
|
+
"scroll_page_up": ["Shift+PgUp"],
|
|
26
|
+
"scroll_page_down": ["Shift+PgDown"],
|
|
27
|
+
"scroll_to_top": ["Shift+Home"],
|
|
28
|
+
"scroll_to_bottom": ["Shift+End"],
|
|
29
|
+
"zoom_in": ["Ctrl+=", "Ctrl++"],
|
|
30
|
+
"zoom_out": ["Ctrl+-"],
|
|
31
|
+
"zoom_reset": ["Ctrl+0"],
|
|
32
|
+
"blocks.previous": ["Up"],
|
|
33
|
+
"blocks.next": ["Down"],
|
|
34
|
+
"blocks.extend_previous": ["Shift+Up"],
|
|
35
|
+
"blocks.extend_next": ["Shift+Down"],
|
|
36
|
+
"blocks.scroll_to_top": ["Ctrl+Shift+Up"],
|
|
37
|
+
"blocks.scroll_to_bottom": ["Ctrl+Shift+Down"],
|
|
38
|
+
"blocks.first": ["Home"],
|
|
39
|
+
"blocks.last": ["End"],
|
|
40
|
+
"blocks.page_up": ["PgUp"],
|
|
41
|
+
"blocks.page_down": ["PgDown"],
|
|
42
|
+
"blocks.focus_input": ["Esc"],
|
|
43
|
+
"blocks.menu": ["Menu"],
|
|
44
|
+
"blocks.toggle_collapse": [],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#: The ways a command can be typed; the first is the default.
|
|
48
|
+
INPUTS = ("editor", "shell")
|
|
49
|
+
|
|
50
|
+
DEFAULT_CONFIG = '''\
|
|
51
|
+
# jupyterminal configuration. Every key is optional.
|
|
52
|
+
|
|
53
|
+
[font]
|
|
54
|
+
family = "" # empty: the system's fixed-width font
|
|
55
|
+
size = 11.0
|
|
56
|
+
|
|
57
|
+
[shell]
|
|
58
|
+
program = "" # empty: $SHELL if it is zsh, otherwise zsh from PATH
|
|
59
|
+
login = false # start it as a login shell
|
|
60
|
+
|
|
61
|
+
[terminal]
|
|
62
|
+
# History cap, in output lines. The oldest blocks are dropped whole.
|
|
63
|
+
scrollback_lines = 2000000
|
|
64
|
+
# Output that arrives while the prompt is idle goes into a background
|
|
65
|
+
# block instead of landing on the prompt.
|
|
66
|
+
divert_background_output = true
|
|
67
|
+
confirm_multiline_paste = true
|
|
68
|
+
# How a command is typed. "editor": in a text box that edits like every
|
|
69
|
+
# other one on the desktop: the mouse places the cursor, Shift and the
|
|
70
|
+
# arrows select, Ctrl+C, Ctrl+X and Ctrl+V are the clipboard, Ctrl+Z takes
|
|
71
|
+
# back. "shell": in the shell's own line editor, as in other terminals.
|
|
72
|
+
input = "editor"
|
|
73
|
+
# Characters besides letters and digits that a double-click treats as
|
|
74
|
+
# part of a word.
|
|
75
|
+
word_characters = "-_./~:@%+#=?&"
|
|
76
|
+
bell = "none" # "none" or "visual"
|
|
77
|
+
# Let programs set the clipboard (OSC 52).
|
|
78
|
+
allow_clipboard_write = true
|
|
79
|
+
|
|
80
|
+
[keys]
|
|
81
|
+
# action = "Shortcut" or ["Shortcut", "Another"]. An empty list unbinds.
|
|
82
|
+
copy = "Ctrl+Shift+C"
|
|
83
|
+
copy_command_and_output = "Ctrl+Alt+Shift+C"
|
|
84
|
+
paste = "Ctrl+Shift+V"
|
|
85
|
+
paste_selection = "Shift+Ins"
|
|
86
|
+
select_newest_block = "Ctrl+Up"
|
|
87
|
+
clear_blocks = "Ctrl+Shift+K"
|
|
88
|
+
find_in_block = "Ctrl+Shift+F"
|
|
89
|
+
filter_output = "Ctrl+Shift+L"
|
|
90
|
+
scroll_page_up = "Shift+PgUp"
|
|
91
|
+
scroll_page_down = "Shift+PgDown"
|
|
92
|
+
scroll_to_top = "Shift+Home"
|
|
93
|
+
scroll_to_bottom = "Shift+End"
|
|
94
|
+
zoom_in = ["Ctrl+=", "Ctrl++"]
|
|
95
|
+
zoom_out = "Ctrl+-"
|
|
96
|
+
zoom_reset = "Ctrl+0"
|
|
97
|
+
|
|
98
|
+
[keys.blocks]
|
|
99
|
+
# These apply while a block is selected.
|
|
100
|
+
previous = "Up"
|
|
101
|
+
next = "Down"
|
|
102
|
+
extend_previous = "Shift+Up"
|
|
103
|
+
extend_next = "Shift+Down"
|
|
104
|
+
scroll_to_top = "Ctrl+Shift+Up"
|
|
105
|
+
scroll_to_bottom = "Ctrl+Shift+Down"
|
|
106
|
+
first = "Home"
|
|
107
|
+
last = "End"
|
|
108
|
+
page_up = "PgUp"
|
|
109
|
+
page_down = "PgDown"
|
|
110
|
+
focus_input = "Esc"
|
|
111
|
+
menu = "Menu"
|
|
112
|
+
toggle_collapse = [] # unbound: any printable key returns to the input
|
|
113
|
+
|
|
114
|
+
[colors]
|
|
115
|
+
# Any color of the theme, as "#rrggbb" or by name. The defaults:
|
|
116
|
+
# background = "#1a1c22"
|
|
117
|
+
# foreground = "#d5d8df"
|
|
118
|
+
# dim = "#7d8594"
|
|
119
|
+
# header_background = "#22252d"
|
|
120
|
+
# header_foreground = "#eef0f4"
|
|
121
|
+
# divider = "#525a6e"
|
|
122
|
+
# selected_background = "#1c2738"
|
|
123
|
+
# selected_header = "#25344f"
|
|
124
|
+
# selected_bar = "#4d8eff"
|
|
125
|
+
# failed_background = "#271b1e"
|
|
126
|
+
# failed_header = "#382024"
|
|
127
|
+
# failed_bar = "#e5534b"
|
|
128
|
+
# failed_foreground = "#ff7b72"
|
|
129
|
+
# accent = "#58a6ff"
|
|
130
|
+
# input_background = "#14161b"
|
|
131
|
+
# input_border = "#333845"
|
|
132
|
+
# cursor = "#d5d8df"
|
|
133
|
+
# cursor_text = "#1a1c22"
|
|
134
|
+
# selection = "#34507f"
|
|
135
|
+
# find = "#6a5a19"
|
|
136
|
+
# find_current = "#d4a72c"
|
|
137
|
+
# find_current_text = "#1a1c22"
|
|
138
|
+
# chip_background = "#2d323d"
|
|
139
|
+
# chip_foreground = "#aab1bf"
|
|
140
|
+
# scrollbar = "#3a404d"
|
|
141
|
+
# The sixteen colors programs choose by number: black, red, green,
|
|
142
|
+
# yellow, blue, magenta, cyan and white, then the bright one of each.
|
|
143
|
+
# ansi = ["#2b303b", "#e06c75", "#98c379", "#e5c07b",
|
|
144
|
+
# "#61afef", "#c678dd", "#56b6c2", "#c8ccd4",
|
|
145
|
+
# "#5c6370", "#ff7b86", "#b1e18b", "#f2d08a",
|
|
146
|
+
# "#7cc0ff", "#d7a1f0", "#74d2de", "#ffffff"]
|
|
147
|
+
'''
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class Config:
|
|
152
|
+
font_family: str = ""
|
|
153
|
+
font_size: float = 11.0
|
|
154
|
+
shell: str = ""
|
|
155
|
+
login_shell: bool = False
|
|
156
|
+
scrollback_lines: int = 2_000_000
|
|
157
|
+
divert_background_output: bool = True
|
|
158
|
+
confirm_multiline_paste: bool = True
|
|
159
|
+
input: str = "editor"
|
|
160
|
+
word_characters: str = "-_./~:@%+#=?&"
|
|
161
|
+
bell: str = "none"
|
|
162
|
+
allow_clipboard_write: bool = True
|
|
163
|
+
keys: dict[str, list[str]] = field(
|
|
164
|
+
default_factory=lambda: {k: list(v) for k, v in DEFAULT_KEYS.items()})
|
|
165
|
+
colors: dict = field(default_factory=dict)
|
|
166
|
+
#: Problems found while reading the file, for the user to see.
|
|
167
|
+
warnings: list[str] = field(default_factory=list)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def default_path() -> str:
|
|
171
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
172
|
+
return os.path.join(base, "jupyterminal", "config.toml")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _shortcuts(value) -> list[str] | None:
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
return [value] if value else []
|
|
178
|
+
if isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
179
|
+
return [v for v in value if v]
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load(path: str | None = None) -> Config:
|
|
184
|
+
"""Read the configuration; a missing file gives the defaults."""
|
|
185
|
+
config = Config()
|
|
186
|
+
path = path or default_path()
|
|
187
|
+
try:
|
|
188
|
+
with open(path, "rb") as handle:
|
|
189
|
+
data = tomllib.load(handle)
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
return config
|
|
192
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
193
|
+
config.warnings.append(f"{path}: {error}")
|
|
194
|
+
return config
|
|
195
|
+
|
|
196
|
+
def take(section: str, key: str, attr: str, kind) -> None:
|
|
197
|
+
table = data.get(section)
|
|
198
|
+
if not isinstance(table, dict) or key not in table:
|
|
199
|
+
return
|
|
200
|
+
value = table[key]
|
|
201
|
+
if kind is float and isinstance(value, int) \
|
|
202
|
+
and not isinstance(value, bool):
|
|
203
|
+
value = float(value)
|
|
204
|
+
if isinstance(value, kind) and not (kind is int
|
|
205
|
+
and isinstance(value, bool)):
|
|
206
|
+
setattr(config, attr, value)
|
|
207
|
+
else:
|
|
208
|
+
config.warnings.append(
|
|
209
|
+
f"{path}: [{section}] {key} should be {kind.__name__}")
|
|
210
|
+
|
|
211
|
+
take("font", "family", "font_family", str)
|
|
212
|
+
take("font", "size", "font_size", float)
|
|
213
|
+
take("shell", "program", "shell", str)
|
|
214
|
+
take("shell", "login", "login_shell", bool)
|
|
215
|
+
take("terminal", "scrollback_lines", "scrollback_lines", int)
|
|
216
|
+
take("terminal", "divert_background_output",
|
|
217
|
+
"divert_background_output", bool)
|
|
218
|
+
take("terminal", "confirm_multiline_paste",
|
|
219
|
+
"confirm_multiline_paste", bool)
|
|
220
|
+
take("terminal", "input", "input", str)
|
|
221
|
+
if config.input not in INPUTS:
|
|
222
|
+
config.warnings.append(
|
|
223
|
+
f"{path}: [terminal] input should be one of "
|
|
224
|
+
+ ", ".join(repr(name) for name in INPUTS)
|
|
225
|
+
+ f", not {config.input!r}")
|
|
226
|
+
config.input = INPUTS[0]
|
|
227
|
+
take("terminal", "word_characters", "word_characters", str)
|
|
228
|
+
take("terminal", "bell", "bell", str)
|
|
229
|
+
take("terminal", "allow_clipboard_write", "allow_clipboard_write", bool)
|
|
230
|
+
|
|
231
|
+
keys = data.get("keys")
|
|
232
|
+
if isinstance(keys, dict):
|
|
233
|
+
for name, value in keys.items():
|
|
234
|
+
if name == "blocks" and isinstance(value, dict):
|
|
235
|
+
for sub, sub_value in value.items():
|
|
236
|
+
_bind(config, path, f"blocks.{sub}", sub_value)
|
|
237
|
+
else:
|
|
238
|
+
_bind(config, path, name, value)
|
|
239
|
+
colors = data.get("colors")
|
|
240
|
+
if isinstance(colors, dict):
|
|
241
|
+
config.colors = colors
|
|
242
|
+
return config
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _bind(config: Config, path: str, action: str, value) -> None:
|
|
246
|
+
shortcuts = _shortcuts(value)
|
|
247
|
+
if action not in DEFAULT_KEYS:
|
|
248
|
+
config.warnings.append(f"{path}: unknown action {action!r}")
|
|
249
|
+
elif shortcuts is None:
|
|
250
|
+
config.warnings.append(
|
|
251
|
+
f"{path}: {action} should be a shortcut or a list of them")
|
|
252
|
+
else:
|
|
253
|
+
config.keys[action] = shortcuts
|
jupyterminal/desktop.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A place on the desktop: the icon, and the entry that puts the program
|
|
2
|
+
into the desktop's menus.
|
|
3
|
+
|
|
4
|
+
A desktop knows an application by an entry, a file named after it. A
|
|
5
|
+
window that says which entry it belongs to gets that entry's icon in the
|
|
6
|
+
task bar and in the window switcher; on Wayland that is the only way to
|
|
7
|
+
an icon of its own. :func:`install` writes the entry for the user who
|
|
8
|
+
runs it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from importlib import resources
|
|
16
|
+
|
|
17
|
+
#: What the desktop knows the program by: its entry is ``NAME.desktop``.
|
|
18
|
+
NAME = "jupyterminal"
|
|
19
|
+
|
|
20
|
+
#: Characters that an argument of a command may only hold in quotes.
|
|
21
|
+
_RESERVED = frozenset(" \t\n\"'\\><~|&;$*?#()`")
|
|
22
|
+
#: Those that take a backslash in front even between quotes.
|
|
23
|
+
_ESCAPED = frozenset("\"`$\\")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def icon_path() -> str:
|
|
27
|
+
"""The file that holds the icon."""
|
|
28
|
+
return str(resources.files("jupyterminal.ui") / (NAME + ".svg"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def launcher() -> list[str]:
|
|
32
|
+
"""The command that starts the program the way it was started this
|
|
33
|
+
time: as a module, or by a script such as the installed command or
|
|
34
|
+
the ``main.py`` of a checkout."""
|
|
35
|
+
spec = getattr(sys.modules.get("__main__"), "__spec__", None)
|
|
36
|
+
if spec is not None and spec.name == NAME + ".__main__":
|
|
37
|
+
return [sys.executable, "-m", NAME]
|
|
38
|
+
return [sys.executable, os.path.abspath(sys.argv[0])]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _word(word: str) -> str:
|
|
42
|
+
"""One argument the way the ``Exec`` line of an entry writes it."""
|
|
43
|
+
word = word.replace("%", "%%") # a single one starts a field code
|
|
44
|
+
if word and not _RESERVED.intersection(word):
|
|
45
|
+
return word
|
|
46
|
+
quoted = "".join("\\" + ch if ch in _ESCAPED else ch for ch in word)
|
|
47
|
+
# The line is a string to the desktop before it is a command: every
|
|
48
|
+
# backslash is written twice.
|
|
49
|
+
return '"' + quoted.replace("\\", "\\\\") + '"'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def entry(command: list[str]) -> str:
|
|
53
|
+
"""The text of the entry that starts the program with ``command``."""
|
|
54
|
+
return "\n".join((
|
|
55
|
+
"[Desktop Entry]",
|
|
56
|
+
"Type=Application",
|
|
57
|
+
"Name=" + NAME,
|
|
58
|
+
"GenericName=Terminal",
|
|
59
|
+
"Comment=A terminal in which typing and copying work as everywhere"
|
|
60
|
+
" else",
|
|
61
|
+
"Exec=" + " ".join(_word(word) for word in command),
|
|
62
|
+
"Icon=" + icon_path().replace("\\", "\\\\"),
|
|
63
|
+
"Terminal=false",
|
|
64
|
+
"Categories=System;TerminalEmulator;",
|
|
65
|
+
# Windows on X11 are matched to the entry by this.
|
|
66
|
+
"StartupWMClass=" + NAME,
|
|
67
|
+
""))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def entry_path() -> str:
|
|
71
|
+
"""Where the entry of the user who runs this goes."""
|
|
72
|
+
base = os.environ.get("XDG_DATA_HOME") \
|
|
73
|
+
or os.path.expanduser("~/.local/share")
|
|
74
|
+
return os.path.join(base, "applications", NAME + ".desktop")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def install(command: list[str]) -> str:
|
|
78
|
+
"""Write the entry that starts the program with ``command`` and
|
|
79
|
+
return its path. An entry that is there already is replaced."""
|
|
80
|
+
path = entry_path()
|
|
81
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
82
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
83
|
+
handle.write(entry(command))
|
|
84
|
+
return path
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The session model: blocks, the router that fills them, and the text
|
|
2
|
+
that copying takes out of them."""
|
|
3
|
+
|
|
4
|
+
from .block import BACKGROUND, COMMAND, Block, git_branch
|
|
5
|
+
from .session import CLOSED, PROMPT, RUNNING, STARTUP, Session
|
|
6
|
+
from .text import (BOTH, FilterSpec, block_text, copy_text, filter_indices,
|
|
7
|
+
find_matches, output_text)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BACKGROUND", "BOTH", "Block", "CLOSED", "COMMAND", "FilterSpec",
|
|
11
|
+
"PROMPT", "RUNNING", "STARTUP", "Session", "block_text", "copy_text",
|
|
12
|
+
"filter_indices", "find_matches", "git_branch", "output_text",
|
|
13
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The block: one command and everything it printed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from ..term import LineStore, Screen
|
|
9
|
+
|
|
10
|
+
COMMAND = "command"
|
|
11
|
+
BACKGROUND = "background"
|
|
12
|
+
|
|
13
|
+
RUNNING = "running"
|
|
14
|
+
FINISHED = "finished"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Block:
|
|
18
|
+
"""One entry of the session.
|
|
19
|
+
|
|
20
|
+
While the command runs, ``screen`` is the live terminal screen it draws
|
|
21
|
+
on and ``store`` receives the lines that scroll off it. When the
|
|
22
|
+
command finishes the screen is frozen into the store and dropped; from
|
|
23
|
+
then on the block is just its logical lines.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("id", "kind", "command", "cwd", "branch", "label",
|
|
27
|
+
"started", "_t0", "duration", "exit_code", "state",
|
|
28
|
+
"not_run", "fullscreen", "cleared", "store", "screen",
|
|
29
|
+
"collapsed", "filter", "visible", "find", "view")
|
|
30
|
+
|
|
31
|
+
def __init__(self, kind: str, cols: int, rows: int, command: str = "",
|
|
32
|
+
cwd: str = "", branch: str = "") -> None:
|
|
33
|
+
self.id = 0
|
|
34
|
+
self.kind = kind
|
|
35
|
+
#: The exact text the user submitted ("" for background output).
|
|
36
|
+
self.command = command
|
|
37
|
+
self.cwd = cwd
|
|
38
|
+
self.branch = branch
|
|
39
|
+
self.label = "background output" if kind == BACKGROUND else ""
|
|
40
|
+
self.started = time.time()
|
|
41
|
+
self._t0 = time.monotonic()
|
|
42
|
+
self.duration: float | None = None
|
|
43
|
+
self.exit_code: int | None = None
|
|
44
|
+
self.state = RUNNING
|
|
45
|
+
#: The shell turned the text down before running anything.
|
|
46
|
+
self.not_run = False
|
|
47
|
+
#: A full-screen program ran in this block.
|
|
48
|
+
self.fullscreen = False
|
|
49
|
+
self.cleared = False
|
|
50
|
+
self.store = LineStore()
|
|
51
|
+
self.screen: Screen | None = Screen(cols, rows, self.store)
|
|
52
|
+
# View state, owned by the user interface.
|
|
53
|
+
self.collapsed = False
|
|
54
|
+
self.filter = None
|
|
55
|
+
self.visible: list[int] | None = None
|
|
56
|
+
self.find = None
|
|
57
|
+
self.view = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def running(self) -> bool:
|
|
61
|
+
return self.state == RUNNING
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def failed(self) -> bool:
|
|
65
|
+
return self.state == FINISHED and self.exit_code not in (None, 0)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_command(self) -> bool:
|
|
69
|
+
return self.kind == COMMAND
|
|
70
|
+
|
|
71
|
+
def elapsed(self) -> float:
|
|
72
|
+
if self.duration is not None:
|
|
73
|
+
return self.duration
|
|
74
|
+
return time.monotonic() - self._t0
|
|
75
|
+
|
|
76
|
+
def finish(self, exit_code: int | None) -> None:
|
|
77
|
+
"""The command is over: freeze its output."""
|
|
78
|
+
if self.state == FINISHED:
|
|
79
|
+
return
|
|
80
|
+
self.duration = time.monotonic() - self._t0
|
|
81
|
+
self.exit_code = exit_code
|
|
82
|
+
self.state = FINISHED
|
|
83
|
+
# A running block always has its screen: this is the one place
|
|
84
|
+
# that takes it away.
|
|
85
|
+
self.screen.finish()
|
|
86
|
+
self.screen = None
|
|
87
|
+
|
|
88
|
+
def has_output(self) -> bool:
|
|
89
|
+
store = self.store
|
|
90
|
+
if store.texts or store.open_parts is not None:
|
|
91
|
+
return True
|
|
92
|
+
screen = self.screen
|
|
93
|
+
if screen is not None:
|
|
94
|
+
for row in screen.grid:
|
|
95
|
+
if not row.is_blank():
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def lines(self) -> list[str]:
|
|
100
|
+
"""Every logical line of output so far."""
|
|
101
|
+
screen = self.screen
|
|
102
|
+
if screen is None:
|
|
103
|
+
return self.store.texts
|
|
104
|
+
return [text for text, _ in screen.all_lines()]
|
|
105
|
+
|
|
106
|
+
def line_count(self) -> int:
|
|
107
|
+
"""``len(self.lines())``, at no more than the cost of what is on
|
|
108
|
+
the screen."""
|
|
109
|
+
screen = self.screen
|
|
110
|
+
if screen is None:
|
|
111
|
+
return len(self.store.texts)
|
|
112
|
+
return screen.line_count()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def git_branch(path: str) -> str:
|
|
116
|
+
"""The checked-out branch of the repository containing ``path``.
|
|
117
|
+
|
|
118
|
+
Read straight from the repository files; returns "" outside one.
|
|
119
|
+
"""
|
|
120
|
+
if not path:
|
|
121
|
+
return ""
|
|
122
|
+
try:
|
|
123
|
+
current = os.path.abspath(path)
|
|
124
|
+
while True:
|
|
125
|
+
dot_git = os.path.join(current, ".git")
|
|
126
|
+
if os.path.isdir(dot_git):
|
|
127
|
+
git_dir = dot_git
|
|
128
|
+
break
|
|
129
|
+
if os.path.isfile(dot_git):
|
|
130
|
+
with open(dot_git, encoding="utf-8",
|
|
131
|
+
errors="replace") as handle:
|
|
132
|
+
pointer = handle.read().strip()
|
|
133
|
+
if not pointer.startswith("gitdir:"):
|
|
134
|
+
return ""
|
|
135
|
+
git_dir = os.path.join(current, pointer[7:].strip())
|
|
136
|
+
break
|
|
137
|
+
parent = os.path.dirname(current)
|
|
138
|
+
if parent == current:
|
|
139
|
+
return ""
|
|
140
|
+
current = parent
|
|
141
|
+
with open(os.path.join(git_dir, "HEAD"), encoding="utf-8",
|
|
142
|
+
errors="replace") as handle:
|
|
143
|
+
head = handle.read().strip()
|
|
144
|
+
except OSError:
|
|
145
|
+
return ""
|
|
146
|
+
if head.startswith("ref:"):
|
|
147
|
+
ref = head[4:].strip()
|
|
148
|
+
prefix = "refs/heads/"
|
|
149
|
+
return ref[len(prefix):] if ref.startswith(prefix) else ref
|
|
150
|
+
return head[:7]
|