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.
Files changed (38) hide show
  1. jupyterminal/__init__.py +13 -0
  2. jupyterminal/__main__.py +7 -0
  3. jupyterminal/app.py +111 -0
  4. jupyterminal/config.py +253 -0
  5. jupyterminal/desktop.py +84 -0
  6. jupyterminal/session/__init__.py +13 -0
  7. jupyterminal/session/block.py +150 -0
  8. jupyterminal/session/session.py +1044 -0
  9. jupyterminal/session/text.py +138 -0
  10. jupyterminal/shell/__init__.py +7 -0
  11. jupyterminal/shell/integration.py +157 -0
  12. jupyterminal/shell/pty.py +203 -0
  13. jupyterminal/shell/zsh/integration.zsh +433 -0
  14. jupyterminal/shell/zsh/zlogin.zsh +13 -0
  15. jupyterminal/shell/zsh/zprofile.zsh +12 -0
  16. jupyterminal/shell/zsh/zshenv.zsh +87 -0
  17. jupyterminal/shell/zsh/zshrc.zsh +19 -0
  18. jupyterminal/term/__init__.py +9 -0
  19. jupyterminal/term/charwidth.py +69 -0
  20. jupyterminal/term/keys.py +113 -0
  21. jupyterminal/term/screen.py +1406 -0
  22. jupyterminal/term/style.py +226 -0
  23. jupyterminal/term/terminal.py +716 -0
  24. jupyterminal/ui/__init__.py +6 -0
  25. jupyterminal/ui/bars.py +212 -0
  26. jupyterminal/ui/cell.py +541 -0
  27. jupyterminal/ui/jupyterminal.svg +12 -0
  28. jupyterminal/ui/layout.py +358 -0
  29. jupyterminal/ui/render.py +315 -0
  30. jupyterminal/ui/theme.py +149 -0
  31. jupyterminal/ui/view.py +2665 -0
  32. jupyterminal/ui/window.py +57 -0
  33. jupyterminal-0.0.1.dev0.dist-info/METADATA +245 -0
  34. jupyterminal-0.0.1.dev0.dist-info/RECORD +38 -0
  35. jupyterminal-0.0.1.dev0.dist-info/WHEEL +5 -0
  36. jupyterminal-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  37. jupyterminal-0.0.1.dev0.dist-info/licenses/LICENSE +201 -0
  38. jupyterminal-0.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -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'
@@ -0,0 +1,7 @@
1
+ """``python -m jupyterminal``."""
2
+
3
+ import sys
4
+
5
+ from .app import main
6
+
7
+ sys.exit(main())
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
@@ -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]