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.
@@ -0,0 +1,4 @@
1
+ from .align import align_text
2
+ from .buffer import ScreenBuffer
3
+
4
+ __all__ = ["ScreenBuffer", "align_text"]
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