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/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