screenbuffer 0.1.0__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.
- screenbuffer/__init__.py +4 -0
- screenbuffer/align.py +58 -0
- screenbuffer/buffer.py +568 -0
- screenbuffer/store.py +230 -0
- screenbuffer/view.py +125 -0
- screenbuffer-0.1.0.dist-info/METADATA +505 -0
- screenbuffer-0.1.0.dist-info/RECORD +9 -0
- screenbuffer-0.1.0.dist-info/WHEEL +5 -0
- screenbuffer-0.1.0.dist-info/top_level.txt +1 -0
screenbuffer/__init__.py
ADDED
screenbuffer/align.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Text alignment helper (no dependencies, easy to unit-test)."""
|
|
2
|
+
import shutil
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def terminal_width():
|
|
7
|
+
return shutil.get_terminal_size().columns
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Width of text as the terminal shows it (emoji and CJK take 2 columns,
|
|
11
|
+
# combining marks take 0), used to pad lines with trailing spaces.
|
|
12
|
+
def _char_width(ch):
|
|
13
|
+
if unicodedata.category(ch) in ("Mn", "Me", "Cf", "Cc"):
|
|
14
|
+
return 0
|
|
15
|
+
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def display_width(text):
|
|
19
|
+
return sum(_char_width(ch) for ch in text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def align_text(text, alignment=None, width=None):
|
|
23
|
+
"""Align `text` inside `width` columns (defaults to the terminal width).
|
|
24
|
+
|
|
25
|
+
alignment:
|
|
26
|
+
None / 'left' -> unchanged
|
|
27
|
+
'center' -> centred
|
|
28
|
+
'right' -> right-justified
|
|
29
|
+
int >= 0 -> that many spaces of left indent
|
|
30
|
+
int < 0 -> ends that many columns from the right edge
|
|
31
|
+
numeric string -> treated like the int ("5", "-10")
|
|
32
|
+
"""
|
|
33
|
+
if width is None:
|
|
34
|
+
width = terminal_width()
|
|
35
|
+
|
|
36
|
+
if alignment is None:
|
|
37
|
+
alignment = "left"
|
|
38
|
+
|
|
39
|
+
if isinstance(alignment, str):
|
|
40
|
+
alignment = alignment.strip()
|
|
41
|
+
if alignment.lstrip("-").isdigit():
|
|
42
|
+
alignment = int(alignment)
|
|
43
|
+
|
|
44
|
+
if isinstance(alignment, int):
|
|
45
|
+
if alignment >= 0:
|
|
46
|
+
return " " * alignment + text
|
|
47
|
+
end_col = width + alignment
|
|
48
|
+
start_col = max(0, end_col - len(text))
|
|
49
|
+
return " " * start_col + text
|
|
50
|
+
|
|
51
|
+
if isinstance(alignment, str):
|
|
52
|
+
keyword = alignment.lower()
|
|
53
|
+
if keyword == "center":
|
|
54
|
+
return text.center(width)
|
|
55
|
+
if keyword == "right":
|
|
56
|
+
return text.rjust(width)
|
|
57
|
+
|
|
58
|
+
return text
|
screenbuffer/buffer.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""ScreenBuffer: state, public API and display.
|
|
2
|
+
|
|
3
|
+
Alignment lives in align.py, file I/O in store.py, and filtering/trimming
|
|
4
|
+
in view.py. This class just ties them together.
|
|
5
|
+
|
|
6
|
+
Built for Jupyter. display() uses the notebook's own clear_output(wait=True):
|
|
7
|
+
the old output stays on screen until the new text is ready, then the two are
|
|
8
|
+
swapped, so there is no flicker. The new text is exactly what get_text()
|
|
9
|
+
returns (each line aligned, your spacing untouched).
|
|
10
|
+
|
|
11
|
+
Smooth by design:
|
|
12
|
+
* The new text is flushed straight away, so the swap happens at once.
|
|
13
|
+
* A redraw is skipped when nothing on screen would change.
|
|
14
|
+
* `with sb.batch():` turns several changes into ONE redraw.
|
|
15
|
+
* Only the max_display window is built when drawing, so a frame costs the
|
|
16
|
+
same for 100 lines or 100,000.
|
|
17
|
+
* Disk writes never block drawing: they are queued in order and written
|
|
18
|
+
outside the buffer lock. Appending a line appends to the files (it does not
|
|
19
|
+
rewrite them), and the history trim only reads the file when it is over limit.
|
|
20
|
+
"""
|
|
21
|
+
import collections
|
|
22
|
+
import contextlib
|
|
23
|
+
import os
|
|
24
|
+
import threading
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
|
|
27
|
+
from .align import align_text, terminal_width
|
|
28
|
+
from .store import LogStore
|
|
29
|
+
from .view import build_view, validate_selector
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from IPython.display import clear_output
|
|
33
|
+
except ImportError: # not running where IPython exists (plain script): no clearing
|
|
34
|
+
def clear_output(wait=False):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ScreenBuffer:
|
|
39
|
+
EMOJI_STATES = {
|
|
40
|
+
0: "🟢",
|
|
41
|
+
1: "🟡",
|
|
42
|
+
2: "🔴",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
FILLER = "<<FILLER>>"
|
|
46
|
+
|
|
47
|
+
_PLAIN_SETTINGS = {
|
|
48
|
+
"wait", "max_main", "max_wait", "max_display", "shift_max_display",
|
|
49
|
+
"auto_clean_edges", "auto_clean_silent_only_display",
|
|
50
|
+
"header_alignment", "footer_alignment", "width",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
wait=True,
|
|
56
|
+
log_file=None,
|
|
57
|
+
max_main=None,
|
|
58
|
+
max_wait=None,
|
|
59
|
+
max_display=None,
|
|
60
|
+
start=False,
|
|
61
|
+
header=None,
|
|
62
|
+
header_alignment=None,
|
|
63
|
+
footer=None,
|
|
64
|
+
footer_alignment=None,
|
|
65
|
+
show_header=None,
|
|
66
|
+
show_footer=None,
|
|
67
|
+
silent_display=None,
|
|
68
|
+
only_display=None,
|
|
69
|
+
auto_clean_edges=False,
|
|
70
|
+
auto_clean_silent_only_display=False,
|
|
71
|
+
shift_max_display=True,
|
|
72
|
+
width=None,
|
|
73
|
+
):
|
|
74
|
+
# State first, so nothing can run display() before it exists.
|
|
75
|
+
self.buffer = []
|
|
76
|
+
self.alignments = {}
|
|
77
|
+
self.buffer_lock = threading.RLock()
|
|
78
|
+
self.display_lock = threading.RLock()
|
|
79
|
+
|
|
80
|
+
self._last_text = None # what the screen shows now (skip identical redraws)
|
|
81
|
+
self._batch_depth = 0 # >0 while inside `with sb.batch():`
|
|
82
|
+
self._batch_dirty = False
|
|
83
|
+
self._batch_lock = threading.Lock()
|
|
84
|
+
|
|
85
|
+
# Disk writes are queued (in buffer order) and run outside buffer_lock.
|
|
86
|
+
self._jobs = collections.deque()
|
|
87
|
+
self._io_lock = threading.Lock()
|
|
88
|
+
# Lines the wait file will hold once the queued writes have run.
|
|
89
|
+
# None = unknown, so the next change rewrites the file in full.
|
|
90
|
+
self._wait_n = None
|
|
91
|
+
|
|
92
|
+
self.wait = wait
|
|
93
|
+
|
|
94
|
+
if silent_display is not None and only_display is not None:
|
|
95
|
+
raise ValueError("silent_display and only_display cannot be used simultaneously")
|
|
96
|
+
self.silent_display = validate_selector(silent_display, "silent_display")
|
|
97
|
+
self.only_display = validate_selector(only_display, "only_display")
|
|
98
|
+
|
|
99
|
+
self.max_main = max_main
|
|
100
|
+
self.max_wait = max_wait
|
|
101
|
+
self.max_display = max_display
|
|
102
|
+
self.shift_max_display = shift_max_display
|
|
103
|
+
|
|
104
|
+
# None = detect the terminal width; set it if your environment
|
|
105
|
+
# reports the wrong one (alignment 'center'/'right' depends on it)
|
|
106
|
+
self.width = width
|
|
107
|
+
|
|
108
|
+
self.auto_clean_edges = auto_clean_edges
|
|
109
|
+
self.auto_clean_silent_only_display = auto_clean_silent_only_display
|
|
110
|
+
|
|
111
|
+
self._check_limits(self.max_wait, self.max_display)
|
|
112
|
+
|
|
113
|
+
# header/footer that are memory-only (set with save_to_file=False)
|
|
114
|
+
self._memory_only = set()
|
|
115
|
+
|
|
116
|
+
self.header = header if isinstance(header, str) else None
|
|
117
|
+
self.footer = footer if isinstance(footer, str) else None
|
|
118
|
+
self.show_header = show_header if show_header is not None else bool(self.header)
|
|
119
|
+
self.show_footer = show_footer if show_footer is not None else bool(self.footer)
|
|
120
|
+
self.header_alignment = header_alignment
|
|
121
|
+
self.footer_alignment = footer_alignment
|
|
122
|
+
|
|
123
|
+
self.store = LogStore(log_file, max_main=max_main, max_wait=max_wait)
|
|
124
|
+
|
|
125
|
+
if start:
|
|
126
|
+
self._reset_all()
|
|
127
|
+
else:
|
|
128
|
+
# an explicit header/footer must win over a stale file from an earlier run
|
|
129
|
+
if self.header is not None:
|
|
130
|
+
self.store.write_part("header", self.header)
|
|
131
|
+
if self.footer is not None:
|
|
132
|
+
self.store.write_part("footer", self.footer)
|
|
133
|
+
self.refresh_buffer_from_wait_log()
|
|
134
|
+
|
|
135
|
+
if not self.wait:
|
|
136
|
+
self.display()
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------- file-path properties
|
|
139
|
+
@property
|
|
140
|
+
def log_file(self):
|
|
141
|
+
return self.store.log_file
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def wait_log_file(self):
|
|
145
|
+
return self.store.wait_log_file
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def header_file(self):
|
|
149
|
+
return self.store.header_file
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def footer_file(self):
|
|
153
|
+
return self.store.footer_file
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------- helpers
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _check_limits(max_wait, max_display):
|
|
158
|
+
if max_wait is not None and max_display is not None:
|
|
159
|
+
if max_wait < max_display:
|
|
160
|
+
raise ValueError("max_wait cannot be less than max_display")
|
|
161
|
+
|
|
162
|
+
def _refresh_if_live(self):
|
|
163
|
+
"""Redraw after a change (only in live mode, wait=False)."""
|
|
164
|
+
if self.wait:
|
|
165
|
+
return
|
|
166
|
+
with self._batch_lock:
|
|
167
|
+
if self._batch_depth:
|
|
168
|
+
self._batch_dirty = True # drawn once when the batch ends
|
|
169
|
+
return
|
|
170
|
+
self._draw(skip_same=True)
|
|
171
|
+
|
|
172
|
+
@contextlib.contextmanager
|
|
173
|
+
def batch(self):
|
|
174
|
+
"""with sb.batch(): ... Make several changes and redraw ONCE at the end.
|
|
175
|
+
|
|
176
|
+
Without it, every put()/update() in live mode redraws, so a quick run of
|
|
177
|
+
changes flashes through half-updated screens.
|
|
178
|
+
"""
|
|
179
|
+
with self._batch_lock:
|
|
180
|
+
self._batch_depth += 1
|
|
181
|
+
try:
|
|
182
|
+
yield self
|
|
183
|
+
finally:
|
|
184
|
+
with self._batch_lock:
|
|
185
|
+
self._batch_depth -= 1
|
|
186
|
+
redraw = self._batch_depth == 0 and self._batch_dirty
|
|
187
|
+
if redraw:
|
|
188
|
+
self._batch_dirty = False
|
|
189
|
+
if redraw and not self.wait:
|
|
190
|
+
self._draw(skip_same=True)
|
|
191
|
+
|
|
192
|
+
# ---- ordered, non-blocking disk writes -----------------------------------
|
|
193
|
+
def _queue_wait_write(self, appended_from=None):
|
|
194
|
+
"""Queue the wait-file write for the change just made. Call with
|
|
195
|
+
buffer_lock held.
|
|
196
|
+
|
|
197
|
+
appended_from=i : lines i.. were just added at the end. If the file is in
|
|
198
|
+
step, they are simply appended (cheap).
|
|
199
|
+
otherwise : rewrite the file with the newest max_wait lines.
|
|
200
|
+
"""
|
|
201
|
+
if not self.store.enabled:
|
|
202
|
+
return
|
|
203
|
+
n = len(self.buffer)
|
|
204
|
+
limit = self.max_wait
|
|
205
|
+
if appended_from is not None and self._wait_n is not None:
|
|
206
|
+
added = n - appended_from
|
|
207
|
+
if limit is None or self._wait_n + added <= limit:
|
|
208
|
+
entries = [
|
|
209
|
+
(self.buffer[i], self.alignments.get(i, "left"))
|
|
210
|
+
for i in range(appended_from, n)
|
|
211
|
+
]
|
|
212
|
+
self._wait_n += added
|
|
213
|
+
self._jobs.append(lambda e=entries: self.store.append_wait(e))
|
|
214
|
+
return
|
|
215
|
+
if limit is None:
|
|
216
|
+
start = 0
|
|
217
|
+
elif limit <= 0:
|
|
218
|
+
start = n
|
|
219
|
+
else:
|
|
220
|
+
start = max(n - limit, 0)
|
|
221
|
+
entries = [(self.buffer[i], self.alignments.get(i, "left")) for i in range(start, n)]
|
|
222
|
+
self._wait_n = len(entries)
|
|
223
|
+
self._jobs.append(lambda e=entries: self.store.write_wait(e))
|
|
224
|
+
|
|
225
|
+
def _drain_io(self, block=False):
|
|
226
|
+
"""Write queued jobs in order. If another thread is already writing, it
|
|
227
|
+
will also write ours, so this returns at once (drawing never waits on disk).
|
|
228
|
+
block=True waits for that thread, so everything is on disk when it returns."""
|
|
229
|
+
error = None
|
|
230
|
+
while True:
|
|
231
|
+
if not self._io_lock.acquire(blocking=block):
|
|
232
|
+
break
|
|
233
|
+
try:
|
|
234
|
+
while self._jobs:
|
|
235
|
+
job = self._jobs.popleft()
|
|
236
|
+
try:
|
|
237
|
+
job()
|
|
238
|
+
except Exception as e: # keep writing the rest, report the first failure
|
|
239
|
+
if error is None:
|
|
240
|
+
error = e
|
|
241
|
+
finally:
|
|
242
|
+
self._io_lock.release()
|
|
243
|
+
if not self._jobs: # a job may have been queued meanwhile
|
|
244
|
+
break
|
|
245
|
+
if error is not None:
|
|
246
|
+
raise error
|
|
247
|
+
|
|
248
|
+
def _wait_io(self):
|
|
249
|
+
self._drain_io(block=True)
|
|
250
|
+
|
|
251
|
+
def _reset_all(self):
|
|
252
|
+
with self.buffer_lock:
|
|
253
|
+
self.buffer.clear()
|
|
254
|
+
self.alignments.clear()
|
|
255
|
+
if self.store.enabled:
|
|
256
|
+
self._wait_n = 0 # the files are about to be emptied, so they match
|
|
257
|
+
self.store.reset_all(self.header or "", self.footer or "")
|
|
258
|
+
|
|
259
|
+
def refresh_buffer_from_wait_log(self):
|
|
260
|
+
"""Reload the buffer from the wait log, only if the buffer is empty."""
|
|
261
|
+
if not self.store.enabled:
|
|
262
|
+
return
|
|
263
|
+
entries = self.store.read_wait()
|
|
264
|
+
if not entries:
|
|
265
|
+
return
|
|
266
|
+
with self.buffer_lock:
|
|
267
|
+
if self.buffer:
|
|
268
|
+
return
|
|
269
|
+
self.buffer = [text for text, _ in entries]
|
|
270
|
+
self.alignments = {
|
|
271
|
+
i: align for i, (_, align) in enumerate(entries) if align != "left"
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------- header / footer
|
|
275
|
+
def _resolve_part(self, name, current, show):
|
|
276
|
+
if not show or current is None:
|
|
277
|
+
return ""
|
|
278
|
+
if name in self._memory_only:
|
|
279
|
+
return current
|
|
280
|
+
stored = self.store.read_part(name)
|
|
281
|
+
return stored if stored else current
|
|
282
|
+
|
|
283
|
+
def get_header(self):
|
|
284
|
+
"""Raw header text ('' when hidden). Alignment is applied at display."""
|
|
285
|
+
return self._resolve_part("header", self.header, self.show_header)
|
|
286
|
+
|
|
287
|
+
def get_footer(self):
|
|
288
|
+
"""Raw footer text ('' when hidden). Alignment is applied at display."""
|
|
289
|
+
return self._resolve_part("footer", self.footer, self.show_footer)
|
|
290
|
+
|
|
291
|
+
def set_header(self, new_header, save_to_file=True, refresh=True):
|
|
292
|
+
self.header = new_header if isinstance(new_header, str) else None
|
|
293
|
+
if save_to_file:
|
|
294
|
+
self.store.write_part("header", self.header)
|
|
295
|
+
self._memory_only.discard("header")
|
|
296
|
+
else:
|
|
297
|
+
self._memory_only.add("header")
|
|
298
|
+
if self.header:
|
|
299
|
+
self.show_header = True
|
|
300
|
+
if refresh:
|
|
301
|
+
self._refresh_if_live()
|
|
302
|
+
|
|
303
|
+
def set_footer(self, new_footer, save_to_file=True, refresh=True):
|
|
304
|
+
self.footer = new_footer if isinstance(new_footer, str) else None
|
|
305
|
+
if save_to_file:
|
|
306
|
+
self.store.write_part("footer", self.footer)
|
|
307
|
+
self._memory_only.discard("footer")
|
|
308
|
+
else:
|
|
309
|
+
self._memory_only.add("footer")
|
|
310
|
+
if self.footer:
|
|
311
|
+
self.show_footer = True
|
|
312
|
+
if refresh:
|
|
313
|
+
self._refresh_if_live()
|
|
314
|
+
|
|
315
|
+
# -------------------------------------------------------------- mutation
|
|
316
|
+
def put(self, text, state=0, index=None, fill_value=None, alignment=None):
|
|
317
|
+
# Pick up a saved buffer once, before the first change of this session.
|
|
318
|
+
if not self.buffer and self._wait_n is None:
|
|
319
|
+
self.refresh_buffer_from_wait_log()
|
|
320
|
+
|
|
321
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
322
|
+
emoji = self.EMOJI_STATES.get(state, "🟢")
|
|
323
|
+
if not isinstance(text, str):
|
|
324
|
+
text = str(text)
|
|
325
|
+
fill_value = self.FILLER if fill_value is None else fill_value
|
|
326
|
+
|
|
327
|
+
with self.buffer_lock:
|
|
328
|
+
old_len = len(self.buffer)
|
|
329
|
+
if index is None:
|
|
330
|
+
index = old_len
|
|
331
|
+
self.buffer.append(text)
|
|
332
|
+
appended_from = old_len
|
|
333
|
+
else:
|
|
334
|
+
if index < 0:
|
|
335
|
+
index += old_len
|
|
336
|
+
if index < 0:
|
|
337
|
+
raise IndexError("Negative index out of range after adjustment")
|
|
338
|
+
if index >= old_len:
|
|
339
|
+
self.buffer.extend([fill_value] * (index - old_len))
|
|
340
|
+
self.buffer.append(text)
|
|
341
|
+
appended_from = old_len
|
|
342
|
+
else:
|
|
343
|
+
self.buffer[index] = text
|
|
344
|
+
appended_from = None
|
|
345
|
+
|
|
346
|
+
if alignment is not None:
|
|
347
|
+
self.alignments[index] = alignment
|
|
348
|
+
|
|
349
|
+
if self.store.enabled:
|
|
350
|
+
self._queue_wait_write(appended_from)
|
|
351
|
+
line = f"{now} {emoji} {text} [📍{index}]"
|
|
352
|
+
self._jobs.append(lambda ln=line: self.store.append_log(ln))
|
|
353
|
+
|
|
354
|
+
self._drain_io()
|
|
355
|
+
self._refresh_if_live()
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _line_matches(line, contains, exact):
|
|
359
|
+
if contains is None:
|
|
360
|
+
return True
|
|
361
|
+
line_lower = line.lower()
|
|
362
|
+
contains_lower = contains.lower()
|
|
363
|
+
if exact:
|
|
364
|
+
return contains_lower in line_lower.split()
|
|
365
|
+
return contains_lower in line_lower
|
|
366
|
+
|
|
367
|
+
def delete(self, index, contains=None, exact=True):
|
|
368
|
+
if not isinstance(index, (int, slice)):
|
|
369
|
+
raise TypeError("Index must be int or slice")
|
|
370
|
+
|
|
371
|
+
with self.buffer_lock:
|
|
372
|
+
total = len(self.buffer)
|
|
373
|
+
if isinstance(index, int):
|
|
374
|
+
i = index + total if index < 0 else index
|
|
375
|
+
if not (0 <= i < total):
|
|
376
|
+
raise IndexError("Index out of range")
|
|
377
|
+
targets = [i]
|
|
378
|
+
else:
|
|
379
|
+
targets = range(*index.indices(total))
|
|
380
|
+
|
|
381
|
+
doomed = {
|
|
382
|
+
i for i in targets
|
|
383
|
+
if self._line_matches(self.buffer[i], contains, exact)
|
|
384
|
+
}
|
|
385
|
+
if doomed:
|
|
386
|
+
kept = [i for i in range(total) if i not in doomed]
|
|
387
|
+
# re-key alignments so they follow their lines
|
|
388
|
+
self.alignments = {
|
|
389
|
+
new: self.alignments[old]
|
|
390
|
+
for new, old in enumerate(kept)
|
|
391
|
+
if old in self.alignments
|
|
392
|
+
}
|
|
393
|
+
self.buffer = [self.buffer[i] for i in kept]
|
|
394
|
+
self._queue_wait_write()
|
|
395
|
+
|
|
396
|
+
self._drain_io()
|
|
397
|
+
self._refresh_if_live()
|
|
398
|
+
|
|
399
|
+
def clear(self):
|
|
400
|
+
with self.buffer_lock:
|
|
401
|
+
self.buffer.clear()
|
|
402
|
+
self.alignments.clear()
|
|
403
|
+
if self.store.enabled:
|
|
404
|
+
self._wait_n = 0
|
|
405
|
+
self._jobs.append(self.store.clear_wait)
|
|
406
|
+
self._drain_io()
|
|
407
|
+
self._refresh_if_live()
|
|
408
|
+
|
|
409
|
+
def clear_all(self):
|
|
410
|
+
with self.buffer_lock:
|
|
411
|
+
self.buffer.clear()
|
|
412
|
+
self.alignments.clear()
|
|
413
|
+
if self.store.enabled:
|
|
414
|
+
self._wait_n = 0
|
|
415
|
+
self._jobs.append(lambda: self.store.reset_all("", ""))
|
|
416
|
+
self.header = None
|
|
417
|
+
self.footer = None
|
|
418
|
+
self.show_header = False
|
|
419
|
+
self.show_footer = False
|
|
420
|
+
self._drain_io()
|
|
421
|
+
self._refresh_if_live()
|
|
422
|
+
|
|
423
|
+
def update(self, **kwargs):
|
|
424
|
+
if kwargs.get("silent_display") is not None and kwargs.get("only_display") is not None:
|
|
425
|
+
raise ValueError("silent_display and only_display cannot be used simultaneously")
|
|
426
|
+
|
|
427
|
+
for key in ("silent_display", "only_display"):
|
|
428
|
+
if key in kwargs:
|
|
429
|
+
kwargs[key] = validate_selector(kwargs[key], key)
|
|
430
|
+
|
|
431
|
+
# Validate everything before changing anything, so a failed update
|
|
432
|
+
# leaves the object exactly as it was.
|
|
433
|
+
known = self._PLAIN_SETTINGS | {
|
|
434
|
+
"header", "footer", "show_header", "show_footer",
|
|
435
|
+
"silent_display", "only_display", "log_file",
|
|
436
|
+
}
|
|
437
|
+
for key in kwargs:
|
|
438
|
+
if key not in known:
|
|
439
|
+
raise ValueError(f"Unknown setting: {key}")
|
|
440
|
+
self._check_limits(
|
|
441
|
+
kwargs.get("max_wait", self.max_wait),
|
|
442
|
+
kwargs.get("max_display", self.max_display),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
for key, value in kwargs.items():
|
|
446
|
+
if key == "header":
|
|
447
|
+
self.set_header(value, save_to_file=True, refresh=False)
|
|
448
|
+
elif key == "footer":
|
|
449
|
+
self.set_footer(value, save_to_file=True, refresh=False)
|
|
450
|
+
elif key == "show_header":
|
|
451
|
+
self.show_header = bool(value)
|
|
452
|
+
elif key == "show_footer":
|
|
453
|
+
self.show_footer = bool(value)
|
|
454
|
+
elif key == "silent_display":
|
|
455
|
+
self.silent_display = value
|
|
456
|
+
if value is not None:
|
|
457
|
+
self.only_display = None
|
|
458
|
+
elif key == "only_display":
|
|
459
|
+
self.only_display = value
|
|
460
|
+
if value is not None:
|
|
461
|
+
self.silent_display = None
|
|
462
|
+
elif key == "log_file":
|
|
463
|
+
self._wait_io() # finish writes queued for the old file
|
|
464
|
+
with self.buffer_lock:
|
|
465
|
+
self.store.set_log_file(value)
|
|
466
|
+
self._wait_n = None # new file: next change writes it in full
|
|
467
|
+
else: # a plain setting
|
|
468
|
+
setattr(self, key, value)
|
|
469
|
+
|
|
470
|
+
self.store.max_main = self.max_main
|
|
471
|
+
self.store.max_wait = self.max_wait
|
|
472
|
+
self._refresh_if_live()
|
|
473
|
+
|
|
474
|
+
# --------------------------------------------------------------- display
|
|
475
|
+
def format_text_for_display(self):
|
|
476
|
+
"""Return [[text, alignment], ...] for what should currently be shown."""
|
|
477
|
+
with self.buffer_lock:
|
|
478
|
+
lines = list(self.buffer)
|
|
479
|
+
alignments = dict(self.alignments)
|
|
480
|
+
|
|
481
|
+
return build_view(
|
|
482
|
+
lines,
|
|
483
|
+
alignments,
|
|
484
|
+
header=self.get_header(),
|
|
485
|
+
footer=self.get_footer(),
|
|
486
|
+
header_alignment=self.header_alignment,
|
|
487
|
+
footer_alignment=self.footer_alignment,
|
|
488
|
+
max_display=self.max_display,
|
|
489
|
+
shift_max_display=self.shift_max_display,
|
|
490
|
+
only_display=self.only_display,
|
|
491
|
+
silent_display=self.silent_display,
|
|
492
|
+
clean_silent_only=self.auto_clean_silent_only_display,
|
|
493
|
+
clean_edges=self.auto_clean_edges,
|
|
494
|
+
filler=self.FILLER,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def _width(self):
|
|
498
|
+
return self.width if self.width else terminal_width()
|
|
499
|
+
|
|
500
|
+
def _lines(self):
|
|
501
|
+
width = self._width()
|
|
502
|
+
return [
|
|
503
|
+
align_text(text, alignment, width)
|
|
504
|
+
for text, alignment in self.format_text_for_display()
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
def get_text(self):
|
|
508
|
+
"""The whole view as one string (each line aligned, joined by newline)."""
|
|
509
|
+
return "\n".join(self._lines())
|
|
510
|
+
|
|
511
|
+
def _draw(self, skip_same):
|
|
512
|
+
with self.display_lock:
|
|
513
|
+
text = self.get_text()
|
|
514
|
+
if skip_same and text == self._last_text:
|
|
515
|
+
return # nothing would change on screen
|
|
516
|
+
# wait=True keeps the old output until the new text arrives.
|
|
517
|
+
# With nothing to print it would never be replaced, so clear now.
|
|
518
|
+
clear_output(wait=bool(text))
|
|
519
|
+
if text:
|
|
520
|
+
# flush=True sends the new text immediately; otherwise the
|
|
521
|
+
# notebook may hold it for up to 0.2 s while the old frame stays.
|
|
522
|
+
print(text, flush=True)
|
|
523
|
+
self._last_text = text
|
|
524
|
+
|
|
525
|
+
def display(self):
|
|
526
|
+
"""Replace what is on screen with the current buffer (flicker-free).
|
|
527
|
+
Always draws, even if nothing changed."""
|
|
528
|
+
self._draw(skip_same=False)
|
|
529
|
+
|
|
530
|
+
def read_log(self, last=None, show=True):
|
|
531
|
+
"""Read the history log. The first line of the result is the file's location.
|
|
532
|
+
|
|
533
|
+
last=None -> every entry; last=N -> only the newest N entries.
|
|
534
|
+
show=True -> print it (nothing returned, so a notebook doesn't show it twice).
|
|
535
|
+
show=False -> return it as one string instead.
|
|
536
|
+
"""
|
|
537
|
+
if not self.store.enabled:
|
|
538
|
+
raise ValueError("No log_file set, so there is no log to read")
|
|
539
|
+
if last is not None:
|
|
540
|
+
if isinstance(last, bool) or not isinstance(last, int):
|
|
541
|
+
raise TypeError("last must be a whole number or None")
|
|
542
|
+
if last < 0:
|
|
543
|
+
raise ValueError("last cannot be negative")
|
|
544
|
+
|
|
545
|
+
self._wait_io() # make sure queued writes are on disk
|
|
546
|
+
entries = self.store.read_main()
|
|
547
|
+
total = len(entries)
|
|
548
|
+
if last is not None:
|
|
549
|
+
entries = entries[total - last:] if last else []
|
|
550
|
+
|
|
551
|
+
path = os.path.abspath(self.store.log_file)
|
|
552
|
+
shown = f"{len(entries)} of {total} entries" if last is not None else f"{total} entries"
|
|
553
|
+
head = [f"Log file: {path}", f"({shown})", "-" * 40]
|
|
554
|
+
text = "\n".join(head + (entries or ["(no entries)"]))
|
|
555
|
+
if not show:
|
|
556
|
+
return text
|
|
557
|
+
print(text)
|
|
558
|
+
|
|
559
|
+
def display_index(self):
|
|
560
|
+
with self.buffer_lock:
|
|
561
|
+
for i, line in enumerate(self.buffer):
|
|
562
|
+
print(f"[{i}]: {repr(line)}")
|
|
563
|
+
|
|
564
|
+
def flush(self):
|
|
565
|
+
"""Clear the notebook output right away."""
|
|
566
|
+
with self.display_lock:
|
|
567
|
+
clear_output(wait=False)
|
|
568
|
+
self._last_text = None
|