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/store.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""All file I/O for ScreenBuffer lives here.
|
|
2
|
+
|
|
3
|
+
Four files are managed per log name (inside ./screen_buffer_logs):
|
|
4
|
+
<name>.txt append-only history (trimmed to max_main lines)
|
|
5
|
+
<name>_wait.txt current buffer snapshot (one line per entry, trimmed to max_wait)
|
|
6
|
+
<name>_header.txt header text
|
|
7
|
+
<name>_footer.txt footer text
|
|
8
|
+
|
|
9
|
+
The wait file stores RAW text plus its alignment, one entry per line:
|
|
10
|
+
<alignment><TAB><escaped text>
|
|
11
|
+
so reloading restores alignment exactly instead of baking in the old
|
|
12
|
+
terminal width. Old-format files (plain padded text) still load; each
|
|
13
|
+
line is simply treated as left-aligned.
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
_ALIGN_WORDS = {"left", "center", "right"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _valid_alignment(head):
|
|
22
|
+
head = head.strip()
|
|
23
|
+
return head.lstrip("-").isdigit() or head.lower() in _ALIGN_WORDS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _escape(text):
|
|
27
|
+
return text.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Every character that text readers treat as the end of a line. In the history
|
|
31
|
+
# log they are written as visible text, so one put() can never create a second
|
|
32
|
+
# (fake) log entry.
|
|
33
|
+
_LINE_BREAKS = str.maketrans({
|
|
34
|
+
"\n": "\\n", "\r": "\\r", "\x0b": "\\x0b", "\x0c": "\\x0c",
|
|
35
|
+
"\x1c": "\\x1c", "\x1d": "\\x1d", "\x1e": "\\x1e", "\x85": "\\x85",
|
|
36
|
+
"\u2028": "\\u2028", "\u2029": "\\u2029",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _one_line(text):
|
|
41
|
+
return text.translate(_LINE_BREAKS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unescape(text):
|
|
45
|
+
out = []
|
|
46
|
+
i = 0
|
|
47
|
+
mapping = {"n": "\n", "r": "\r", "\\": "\\"}
|
|
48
|
+
while i < len(text):
|
|
49
|
+
ch = text[i]
|
|
50
|
+
if ch == "\\" and i + 1 < len(text):
|
|
51
|
+
nxt = text[i + 1]
|
|
52
|
+
out.append(mapping.get(nxt, "\\" + nxt))
|
|
53
|
+
i += 2
|
|
54
|
+
else:
|
|
55
|
+
out.append(ch)
|
|
56
|
+
i += 1
|
|
57
|
+
return "".join(out)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _encode(text, alignment):
|
|
61
|
+
return f"{alignment}\t{_escape(text)}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _decode(line):
|
|
65
|
+
head, sep, rest = line.partition("\t")
|
|
66
|
+
if not sep or not _valid_alignment(head):
|
|
67
|
+
return line, "left" # old-format line
|
|
68
|
+
head = head.strip()
|
|
69
|
+
alignment = int(head) if head.lstrip("-").isdigit() else head.lower()
|
|
70
|
+
return _unescape(rest), alignment
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LogStore:
|
|
74
|
+
LOGS_DIR = "screen_buffer_logs"
|
|
75
|
+
|
|
76
|
+
def __init__(self, log_file=None, max_main=None, max_wait=None):
|
|
77
|
+
self.file_lock = threading.RLock()
|
|
78
|
+
self.max_main = max_main
|
|
79
|
+
self.max_wait = max_wait
|
|
80
|
+
self._main_count = None # history lines in the file, counted in memory
|
|
81
|
+
self.set_log_file(log_file)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ paths
|
|
84
|
+
def set_log_file(self, log_file):
|
|
85
|
+
self._main_count = None # a different file: count it again when needed
|
|
86
|
+
if not log_file:
|
|
87
|
+
self.log_file = None
|
|
88
|
+
self.wait_log_file = None
|
|
89
|
+
self.header_file = None
|
|
90
|
+
self.footer_file = None
|
|
91
|
+
return
|
|
92
|
+
os.makedirs(self.LOGS_DIR, exist_ok=True)
|
|
93
|
+
name = log_file if log_file.lower().endswith(".txt") else f"{log_file}.txt"
|
|
94
|
+
self.log_file = os.path.join(self.LOGS_DIR, name)
|
|
95
|
+
base, ext = os.path.splitext(self.log_file)
|
|
96
|
+
self.wait_log_file = f"{base}_wait{ext}"
|
|
97
|
+
self.header_file = f"{base}_header{ext}"
|
|
98
|
+
self.footer_file = f"{base}_footer{ext}"
|
|
99
|
+
# create the history file right away (never truncates)
|
|
100
|
+
try:
|
|
101
|
+
with open(self.log_file, "a", encoding="utf-8"):
|
|
102
|
+
pass
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def enabled(self):
|
|
108
|
+
return self.log_file is not None
|
|
109
|
+
|
|
110
|
+
def _part_path(self, name):
|
|
111
|
+
return {"header": self.header_file, "footer": self.footer_file}[name]
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------- low-level io
|
|
114
|
+
def _write(self, path, text):
|
|
115
|
+
if not path:
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
with self.file_lock, open(path, "w", encoding="utf-8") as f:
|
|
119
|
+
f.write(text)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def _read(self, path):
|
|
124
|
+
if not path or not os.path.exists(path):
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
with self.file_lock, open(path, "r", encoding="utf-8") as f:
|
|
128
|
+
return f.read()
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def _count_lines(self, path):
|
|
133
|
+
try:
|
|
134
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
135
|
+
return sum(1 for _ in f)
|
|
136
|
+
except Exception:
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
def _trim_to(self, path, limit):
|
|
140
|
+
"""Keep only the newest `limit` lines. Returns how many lines remain."""
|
|
141
|
+
try:
|
|
142
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
143
|
+
lines = f.readlines()
|
|
144
|
+
if len(lines) > limit:
|
|
145
|
+
lines = lines[len(lines) - limit:] if limit > 0 else []
|
|
146
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
147
|
+
f.writelines(lines)
|
|
148
|
+
return len(lines)
|
|
149
|
+
except Exception:
|
|
150
|
+
return self._count_lines(path)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------ main log
|
|
153
|
+
def append_log(self, line):
|
|
154
|
+
"""Append ONE history line (any line breaks inside it are escaped).
|
|
155
|
+
Raises RuntimeError if the write fails."""
|
|
156
|
+
if not self.log_file:
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
with self.file_lock:
|
|
160
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
161
|
+
f.write(_one_line(line) + "\n")
|
|
162
|
+
# The file is only read again when it really is over the limit
|
|
163
|
+
# (the line count is kept in memory, counted once from the file).
|
|
164
|
+
if self._main_count is not None:
|
|
165
|
+
self._main_count += 1
|
|
166
|
+
if self.max_main is not None:
|
|
167
|
+
if self._main_count is None:
|
|
168
|
+
self._main_count = self._count_lines(self.log_file)
|
|
169
|
+
if self._main_count > self.max_main:
|
|
170
|
+
self._main_count = self._trim_to(self.log_file, self.max_main)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
raise RuntimeError(f"Error writing to log file: {e}") from e
|
|
173
|
+
|
|
174
|
+
def read_main(self):
|
|
175
|
+
"""All history lines, oldest first ([] if the file is missing or empty)."""
|
|
176
|
+
content = self._read(self.log_file)
|
|
177
|
+
if not content:
|
|
178
|
+
return []
|
|
179
|
+
lines = content.split("\n")
|
|
180
|
+
if lines and lines[-1] == "":
|
|
181
|
+
lines.pop()
|
|
182
|
+
return lines
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------ wait log
|
|
185
|
+
def write_wait(self, entries):
|
|
186
|
+
"""entries: list of (text, alignment). Trimmed to max_wait lines."""
|
|
187
|
+
if not self.wait_log_file:
|
|
188
|
+
return
|
|
189
|
+
lines = [_encode(text, align) for text, align in entries]
|
|
190
|
+
if self.max_wait is not None:
|
|
191
|
+
lines = lines[len(lines) - self.max_wait:] if self.max_wait > 0 else []
|
|
192
|
+
self._write(self.wait_log_file, "\n".join(lines) + "\n" if lines else "")
|
|
193
|
+
|
|
194
|
+
def append_wait(self, entries):
|
|
195
|
+
"""Add entries to the end of the wait file (no rewrite of what is there)."""
|
|
196
|
+
if not self.wait_log_file or not entries:
|
|
197
|
+
return
|
|
198
|
+
text = "".join(_encode(t, a) + "\n" for t, a in entries)
|
|
199
|
+
try:
|
|
200
|
+
with self.file_lock, open(self.wait_log_file, "a", encoding="utf-8") as f:
|
|
201
|
+
f.write(text)
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
def read_wait(self):
|
|
206
|
+
"""Return a list of (text, alignment); [] when missing or empty."""
|
|
207
|
+
content = self._read(self.wait_log_file)
|
|
208
|
+
if content is None or not content.strip():
|
|
209
|
+
return []
|
|
210
|
+
lines = content.split("\n")
|
|
211
|
+
if lines and lines[-1] == "":
|
|
212
|
+
lines.pop()
|
|
213
|
+
return [_decode(line) for line in lines]
|
|
214
|
+
|
|
215
|
+
def clear_wait(self):
|
|
216
|
+
self._write(self.wait_log_file, "")
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------ header / footer
|
|
219
|
+
def read_part(self, name):
|
|
220
|
+
"""name is 'header' or 'footer'. None when there is no file."""
|
|
221
|
+
return self._read(self._part_path(name))
|
|
222
|
+
|
|
223
|
+
def write_part(self, name, text):
|
|
224
|
+
self._write(self._part_path(name), text or "")
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------- reset
|
|
227
|
+
def reset_all(self, header="", footer=""):
|
|
228
|
+
self.clear_wait()
|
|
229
|
+
self.write_part("header", header)
|
|
230
|
+
self.write_part("footer", footer)
|
screenbuffer/view.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Pure rendering logic: no files, no locks, no printing.
|
|
2
|
+
|
|
3
|
+
build_view() takes the raw buffer and the display options and returns a
|
|
4
|
+
list of [text, alignment] pairs ready to be aligned and printed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_selector(val, name="silent_display"):
|
|
9
|
+
"""Validate a silent_display / only_display value."""
|
|
10
|
+
if val is None:
|
|
11
|
+
return None
|
|
12
|
+
if isinstance(val, (int, slice)):
|
|
13
|
+
return val
|
|
14
|
+
if isinstance(val, list):
|
|
15
|
+
if not all(isinstance(i, int) for i in val):
|
|
16
|
+
raise ValueError(f"{name} list must contain integers")
|
|
17
|
+
return val
|
|
18
|
+
if isinstance(val, str):
|
|
19
|
+
if val.lower() == "all":
|
|
20
|
+
return "all"
|
|
21
|
+
raise ValueError(f"{name} string must be 'all'")
|
|
22
|
+
raise ValueError(f"Invalid {name} value")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _selected_indices(selector, total):
|
|
26
|
+
"""Turn an int / list / slice into a set of absolute buffer indices."""
|
|
27
|
+
if isinstance(selector, slice):
|
|
28
|
+
raw = range(total)[selector]
|
|
29
|
+
elif isinstance(selector, int):
|
|
30
|
+
raw = [selector]
|
|
31
|
+
else:
|
|
32
|
+
raw = selector
|
|
33
|
+
return {i + total if i < 0 else i for i in raw}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tail(rows, n):
|
|
37
|
+
return rows[max(len(rows) - n, 0):]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_view(
|
|
41
|
+
lines,
|
|
42
|
+
alignments,
|
|
43
|
+
*,
|
|
44
|
+
header=None,
|
|
45
|
+
footer=None,
|
|
46
|
+
header_alignment=None,
|
|
47
|
+
footer_alignment=None,
|
|
48
|
+
max_display=None,
|
|
49
|
+
shift_max_display=True,
|
|
50
|
+
only_display=None,
|
|
51
|
+
silent_display=None,
|
|
52
|
+
clean_silent_only=False,
|
|
53
|
+
clean_edges=False,
|
|
54
|
+
filler="<<FILLER>>",
|
|
55
|
+
):
|
|
56
|
+
"""Return [[text, alignment], ...].
|
|
57
|
+
|
|
58
|
+
shift_max_display=True : filter first, then keep the last `max_display`
|
|
59
|
+
visible lines (muted lines don't use up slots).
|
|
60
|
+
shift_max_display=False: keep the last `max_display` buffer lines first,
|
|
61
|
+
then filter.
|
|
62
|
+
"""
|
|
63
|
+
total = len(lines)
|
|
64
|
+
|
|
65
|
+
# Only the last `max_display` rows can ever be shown. When muting cannot
|
|
66
|
+
# change how many rows there are (no clean_silent_only), or the window is
|
|
67
|
+
# taken first (shift_max_display=False), build just that window instead of
|
|
68
|
+
# one row per line: drawing then costs the same for 100 lines or 100,000.
|
|
69
|
+
# Indices stay absolute, so only_display/silent_display still match.
|
|
70
|
+
first = 0
|
|
71
|
+
if max_display is not None and (not shift_max_display or not clean_silent_only):
|
|
72
|
+
first = max(total - max_display, 0)
|
|
73
|
+
|
|
74
|
+
# each row: [text, alignment, original_index]
|
|
75
|
+
rows = []
|
|
76
|
+
for i in range(first, total):
|
|
77
|
+
line = lines[i]
|
|
78
|
+
rows.append(["" if line == filler else line, alignments.get(i, "left"), i])
|
|
79
|
+
|
|
80
|
+
# 1. window
|
|
81
|
+
if max_display is not None and not shift_max_display:
|
|
82
|
+
rows = _tail(rows, max_display)
|
|
83
|
+
|
|
84
|
+
# 2. only_display / silent_display (indices refer to the full buffer)
|
|
85
|
+
if only_display is not None:
|
|
86
|
+
if not (isinstance(only_display, str) and only_display == "all"):
|
|
87
|
+
keep = _selected_indices(only_display, total)
|
|
88
|
+
for row in rows:
|
|
89
|
+
if row[2] not in keep:
|
|
90
|
+
row[0], row[1] = "", "left"
|
|
91
|
+
elif silent_display is not None:
|
|
92
|
+
if isinstance(silent_display, str) and silent_display == "all":
|
|
93
|
+
muted = None
|
|
94
|
+
else:
|
|
95
|
+
muted = _selected_indices(silent_display, total)
|
|
96
|
+
for row in rows:
|
|
97
|
+
if muted is None or row[2] in muted:
|
|
98
|
+
row[0], row[1] = "", "left"
|
|
99
|
+
|
|
100
|
+
# 3. drop blank rows left behind by silent/only
|
|
101
|
+
if clean_silent_only:
|
|
102
|
+
rows = [row for row in rows if row[0].strip() != ""]
|
|
103
|
+
|
|
104
|
+
# 4. window again (shifted mode)
|
|
105
|
+
if max_display is not None:
|
|
106
|
+
rows = _tail(rows, max_display)
|
|
107
|
+
|
|
108
|
+
# 5. trim blank edges, then pad with one blank line top and bottom
|
|
109
|
+
if clean_edges:
|
|
110
|
+
while rows and rows[0][0].strip() == "":
|
|
111
|
+
rows.pop(0)
|
|
112
|
+
while rows and rows[-1][0].strip() == "":
|
|
113
|
+
rows.pop()
|
|
114
|
+
if rows:
|
|
115
|
+
rows.insert(0, ["", "left", -1])
|
|
116
|
+
rows.append(["", "left", -1])
|
|
117
|
+
|
|
118
|
+
# 6. header + body + footer
|
|
119
|
+
result = []
|
|
120
|
+
if header:
|
|
121
|
+
result.append([header.rstrip("\n"), header_alignment or "left"])
|
|
122
|
+
result.extend([text, align] for text, align, _ in rows)
|
|
123
|
+
if footer:
|
|
124
|
+
result.append([footer.lstrip("\n"), footer_alignment or "left"])
|
|
125
|
+
return result
|