diff --git a/docs/plans/view.md b/docs/plans/view.md index 2a420e0..e7f8c9f 100644 --- a/docs/plans/view.md +++ b/docs/plans/view.md @@ -114,17 +114,17 @@ made for it. picked term, so nothing is lost. No colour: reverse video is the terminal's own, and a background chosen here is a bet on somebody's theme. -- ⚠ **Toggling `z` keeps what was SELECTED, not the row numbers.** Both - ends travel as offsets, and the upper one is taken to the END of the - entry it lands in: a joined row is an entry, so unjoining it is several - lines and an offset resolves to the first of them. Keeping the numbers - collapsed a selection of two records to their two first lines — a - highlight that read as lost, and a copy that then took two lines of the - fourteen selected. Joined, every row is a first, so the rule widens - nothing in that direction and needs no test of which way the toggle - went. The cursor is one of those ends, so where it was the upper one - the view follows it to the entry's end; a selection longer than the - screen cannot show both ends anyway. +- ⚠ **Toggling `z`, or opening one entry with `Z`, keeps what was + SELECTED, not the row numbers.** Both ends travel as offsets, and the + upper one is taken to the END of the entry it lands in: a joined row is + an entry, so unjoining it is several lines and an offset resolves to + the first of them. Keeping the numbers collapsed a selection of two + records to their two first lines — a highlight that read as lost, and a + copy that then took two lines of the fourteen selected. Joined, every + row is a first, so the rule widens nothing in that direction and needs + no test of which way the toggle went. The cursor is one of those ends, + so where it was the upper one the view follows it to the entry's end; a + selection longer than the screen cannot show both ends anyway. - **A joined row copies as the log's own lines.** `z` renders an entry as one row with `↵` between its lines; that is a thing to read, and the clipboard wants what the log holds. So `z` then a mark is how entry-wide @@ -175,6 +175,45 @@ multiplexer. there too rather than being handed to something that would truncate it without saying so. +## Built: ten traces on one screen, and one of them opened + +`z` renders every multi-line entry as one row and `Z` opens the entry +under the cursor back out into its own lines. + +- **The question `z` answers is "are these ten the same failure".** Four + hundred lines of trace answer it with no screen to answer it on; ten + rows put the messages in a column where the eye compares them, with + the frames trailing off to the right where `h`/`l` can go and read + them. The continuation lines are LSTRIPPED as they join — their indent + is what puts frames under a message that is no longer above them, and + it is the difference between ten rows that line up and ten that do not. +- **A rendering, not a fold.** Every line of the entry is on the row, so + the search, `Tab`, the hit list and the entry motion all keep working: + each row is a real line with a real address. A fold would have had to + teach every one of those about lines that are not on the screen. +- **And `Z` is the next question**: which is exactly one of the ten, read + in full, while the other nine stay rows to come back to. Several can be + open at once, because comparing two of them is what picking them out of + the ten was for. It is an EXCEPTION to a rendering rather than a second + mode — an open entry is the source's own lines, so its framing is the + entry framing again and a bare `c` still takes the whole trace — and + `z`, which sets the whole screen, builds the join afresh and so clears + the exceptions. +- **Any of its lines closes it**, not just its first: what the reader is + pointing at is the entry, and having to walk back to its head to put it + away is a coordinate they would have to keep. +- ⚠ **An open entry is remembered by its ORDINAL**, the nth entry of the + answer — not by its offset, which is how every other position in this + pager is held. An entry still at a live edge has no offset yet and they + all report 0, so a set of offsets would open every live-edge entry at + once. The answer is a closed set, so the nth entry stays the nth. +- **A decorator over the source, not a mode inside it.** `Tape` and + `Records` both have entries in this sense, the view swaps its source at + runtime when a hit is in another store, and neither should learn about + a display option. On the tape, which parses nothing, every line is its + own entry — so joining is the identity there and `Z` says there is + nothing folded in, exactly as the entry motion is a line there. + ## /etc/hosts, and the DNS that would replace it **The hosts file half is BUILT.** `TIMBERFS_CMD` plus `TIMBERFS_HOSTS` was diff --git a/packaging/timberview.1 b/packaging/timberview.1 index 0a3be44..246594e 100644 --- a/packaging/timberview.1 +++ b/packaging/timberview.1 @@ -150,6 +150,23 @@ all keep working, because every line on the screen is a real line with a real address. .RE .TP +.B Z +Open THIS entry back out of the join, as its own lines \(em the one of +the ten you picked out to read, while the other nine stay rows to come +back to. Pressing it again joins it, from ANY of its lines: what you are +pointing at is the entry, not the row. +.RS +.PP +Several can be open at once, because comparing two traces is the next +thing after picking them out of the ten. It is an exception to a +rendering and not a second mode \(em the entry is still one entry to the +entry motion and to a bare +.BR c , +and +.BR z , +which sets the whole screen, builds the join afresh and so clears them. +.RE +.TP .BR space " " b A page. .BR h " " l " " \(<- " " \(-> @@ -240,7 +257,9 @@ in \(em that rendering is for reading. So .B z and then a mark is how a run of whole entries is selected. Toggling .B z -with a region live keeps what was selected rather than the row numbers: +\(em or opening one entry with +.B Z +\(em with a region live keeps what was selected rather than the row numbers: both ends travel as offsets, and the upper one is taken to the END of the entry it lands in, since a row unjoins into several lines and an offset resolves to the first of them. diff --git a/tests/timberview/test-timberview b/tests/timberview/test-timberview index e0d141a..a8e7429 100755 --- a/tests/timberview/test-timberview +++ b/tests/timberview/test-timberview @@ -927,6 +927,110 @@ def joining_keeps_the_entry_you_are_on(): (v.cur, v.source.lines[v.cur].text) +@test +def one_entry_can_be_opened_back_out_of_the_join(): + """`z` is the whole answer at once, which is what makes ten traces + comparable — and then one of them is the one you read. Opening it is + an exception to the join and not a second mode: everything else stays + a row, and the entry is still one entry to the motions and the copy.""" + frames = "\n".join(f" at frame.{i}(F.java:1)" for i in range(5)) + r = tv.Records(answer(("one", {"offset": 0}), + (f"boom\n{frames}", {"offset": 100}), + ("three", {"offset": 900}))) + v = tv.View(Fake(["x\n"]), source=r) + # Through the KEYS: a binding nothing exercises is one a refactor can + # lose, and `Z` is only reachable through one. + screen = Screen_for_test() + screen.view = v + screen.step(Screenish(), ord("z")) + assert len(v.source.lines) == 3, [ln.text for ln in v.source.lines] + + v.cur = 1 + screen.step(Screenish(), ord("Z")) + assert len(v.source.lines) == 8, [ln.text for ln in v.source.lines] + # The other two are untouched, and the cursor is on what it was on. + assert [v.source.lines[i].text for i in (0, 7)] == ["one", "three"] + assert v.cur == 1 and v.source.lines[1].text == "boom", v.cur + assert "↵" not in "".join(ln.text for ln in v.source.lines) + # Still ONE entry: the whole trace is what a bare `c` takes, and the + # entry motion steps over it. + assert v.entry_span() == (1, 6), v.entry_span() + assert v.selection()[1] == "this entry, 6 lines", v.selection()[1] + v.move_entry(1) + assert v.source.lines[v.cur].text == "three", v.source.lines[v.cur].text + + # From ANY of its lines, because what you point at is the entry. + v.cur = 4 + v.open_entry() + assert len(v.source.lines) == 3, [ln.text for ln in v.source.lines] + assert v.source.lines[v.cur].text.startswith("boom ↵ at frame.0") + + # A one-line entry has nothing folded into it, and says so. + v.cur = 0 + v.open_entry() + assert len(v.source.lines) == 3 and "one line" in v.message, v.message + + +@test +def opening_one_entry_is_a_property_of_the_join(): + """It is an exception to a rendering, so it lives and dies with one: + `z` sets the whole screen, and a screen that is not joined has + nothing to open out of.""" + r = tv.Records(answer(("boom\n at A\n at B", {"offset": 0}), + ("next", {"offset": 100}))) + v = tv.View(Fake(["x\n"]), source=r) + v.open_entry() + assert len(v.source.lines) == 4 and "nothing is joined" in v.message, \ + v.message + + v.join_entries() + v.cur = 0 + v.open_entry() + assert len(v.source.lines) == 4, [ln.text for ln in v.source.lines] + v.join_entries() # everything as written + v.join_entries() # ...and joined again + assert len(v.source.lines) == 2, [ln.text for ln in v.source.lines] + + +@test +def entries_at_a_live_edge_open_one_at_a_time(): + """An entry that has not been placed yet has no offset, and every one + of them reports 0 — so an open entry is remembered by its ORDINAL. By + offset, opening one of them would open all the others with it.""" + r = tv.Records(answer(("first\n at A", {}), ("second\n at B", {}))) + v = tv.View(Fake(["x\n"]), source=r) + assert r.unplaced == 2, r.unplaced + v.join_entries() + v.cur = 1 + v.open_entry() + assert [ln.text for ln in v.source.lines] == \ + ["first ↵ at A", "second", " at B"], \ + [ln.text for ln in v.source.lines] + + +@test +def opening_an_entry_keeps_what_was_selected(): + """The rows renumber under it exactly as they do under `z`, so both + ends of a region travel as offsets — a region that quietly became a + different set of lines is the copy that takes the wrong thing.""" + r = tv.Records(answer(("one", {"offset": 0}), + ("boom\n at A\n at B", {"offset": 100}), + ("three", {"offset": 900}))) + v = tv.View(Fake(["x\n"]), source=r) + v.join_entries() + v.cur = 0 + v.set_mark() + v.move(1) # "one", then the trace + whole = "one\nboom\n at A\n at B\n" + assert v.selection()[0] == whole, v.selection() + + v.open_entry() + assert v.region() == (0, 3), v.region() + assert v.selection()[0] == whole, v.selection() + v.open_entry() + assert v.region() == (0, 1) and v.selection()[0] == whole, v.selection() + + @test def the_join_survives_the_view_changing_store(): """A hit in another store replaces the source outright. A display diff --git a/tools/README.md b/tools/README.md index 56c74ea..058c856 100644 --- a/tools/README.md +++ b/tools/README.md @@ -416,6 +416,15 @@ match. - **A multi-line entry is one entry.** The lines of a stack trace belong to the entry that raised it; splitting them would be the same lie as splitting a line across a chunk boundary. +- **Ten of them is four hundred lines**, and every one pushes the next + entry off the screen — which leaves no screen for the thing you are + doing, deciding whether these ten are the same failure. `z` renders + each entry as ONE row, the trace trailing off to the right of the + message that raised it, and `Z` opens the one you have picked out back + into its own lines while the other nine stay rows. Nothing is hidden + either way: a joined row carries every line of the entry, `h`/`l` read + along it, and the search, the terms and the entry motion keep working + because each row is a real line with a real address. - **An answer is a closed set.** Both ends are ends, nothing extends, and the boundary says "end of the answer" rather than naming a chunk you are not in. diff --git a/tools/timberview.py b/tools/timberview.py index f7344ec..0dd2897 100644 --- a/tools/timberview.py +++ b/tools/timberview.py @@ -1219,7 +1219,11 @@ class Joined: A DECORATOR rather than a mode inside each source: `Tape` and `Records` both have entries in this sense, the view swaps its source at runtime (a hit in another store), and neither of them should learn - about a display option.""" + about a display option. + + An entry can be OPENED out of the join and rendered as its own lines + again — the one of the ten you have decided to read, while the other + nine stay rows to come back to.""" SEP = " ↵ " @@ -1228,6 +1232,15 @@ def __init__(self, source): self._n = None self._lines = [] self._runs = [] + # Which entry each row belongs to, and which entries are open — + # both by ORDINAL, the nth entry of the source, rather than by + # offset: an entry still at a live edge has no offset yet and + # every one of them reports 0, so a set of offsets would open all + # of them together. An answer is a closed set, so the nth entry + # stays the nth — and the tape, where the run does renumber, has + # no multi-line entry to open. + self._of = [] + self._open = set() def __getattr__(self, name): # Everything not about lines is the source's, including the @@ -1258,18 +1271,32 @@ def rows_as_lines(self, lo, hi): return out def _rebuild(self): - out, runs, run = [], [], [] + out, runs, of, run = [], [], [], [] + n = 0 def flush(): + nonlocal n if not run: return - head = run[0] - ln = Line(head.offset, b"") - ln.text = head.text + "".join( - self.SEP + x.text.lstrip() for x in run[1:]) - ln.at, ln.store, ln.wf, ln.first = head.at, head.store, head.wf, True - out.append(ln) - runs.append(list(run)) + if n in self._open: + # The source's OWN Line objects, so the framing on them is + # the entry framing again: the entry motion steps over the + # trace and a bare `c` takes the whole of it, exactly as + # they do with no join at all. + out.extend(run) + runs.extend([ln] for ln in run) + of.extend([n] * len(run)) + else: + head = run[0] + ln = Line(head.offset, b"") + ln.text = head.text + "".join( + self.SEP + x.text.lstrip() for x in run[1:]) + ln.at, ln.store, ln.wf, ln.first = \ + head.at, head.store, head.wf, True + out.append(ln) + runs.append(list(run)) + of.append(n) + n += 1 for line in self.source.lines: if getattr(line, "first", True) and run: @@ -1277,9 +1304,30 @@ def flush(): run = [] run.append(line) flush() - self._lines, self._runs = out, runs + self._lines, self._runs, self._of = out, runs, of self._n = len(self.source.lines) + def open_row(self, row): + """Open the entry rendered at `row` out into its own lines, or + join it again. Whether it is now open, or None where there is + nothing folded into it. + + Any row of an open entry closes it, not just its first: what you + are pointing at is the entry, and having to walk back to its head + to put it away would be a coordinate the reader has to keep.""" + self._fresh() + if not 0 <= row < len(self._of): + return None + n = self._of[row] + if n in self._open: + self._open.discard(n) + elif len(self._runs[row]) < 2: + return None + else: + self._open.add(n) + self._rebuild() + return n in self._open + # An offset lands on the ENTRY holding it, which is the row it is now # part of. The base implementations would scan the source's lines and # answer with an index into the wrong list. @@ -1377,21 +1425,19 @@ def set_window(self, text): self.source.window = (lo, hi) self.message = f"window {when_ms(lo)} .. {when_ms(hi)}" - def join_entries(self, on=None): - """Show each multi-line entry as one row, or stop. + def _rerender(self, change): + """A change to how the lines are RENDERED, keeping the place and + the selection. What `change` returned comes back. - The line under the cursor is kept: its offset addresses the ENTRY - either way, so the same entry is under you before and after, and - a toggle is not also a jump.""" + Rows renumber when entries join or open; offsets do not, so the + cursor and both ends of a region travel as offsets: a display + toggle must not also be a jump, nor change what a copy takes.""" where = self.line() lines = self.source.lines - # Both ends of the region travel as OFFSETS, for the reason the - # cursor does: a display toggle must not change what is selected. span = self.region() ends = (lines[span[0]].offset, lines[span[1]].offset) if span else None mark_low = span is not None and self.mark <= self.cur - self.join = (not self.join) if on is None else on - self.source = self._source # re-wrap, or unwrap + got = change() if where is not None: self.cur = self.source.index_of(where.offset) self.top = min(self.top, self.cur) @@ -1408,8 +1454,44 @@ def join_entries(self, on=None): hi = self.entry_span(at=self.source.index_of(ends[1]))[1] self.mark, self.cur = (lo, hi) if mark_low else (hi, lo) self.tok, self.col = 0, 0 - self.message = ("multi-line entries as one row" if self.join - else "entries as they are written") + return got + + def join_entries(self, on=None): + """Show each multi-line entry as one row, or stop. + + The line under the cursor is kept: its offset addresses the ENTRY + either way, so the same entry is under you before and after, and + a toggle is not also a jump.""" + def flip(): + self.join = (not self.join) if on is None else on + self.source = self._source # re-wrap, or unwrap + self._rerender(flip) + self.message = ("multi-line entries as one row · Z opens the one you " + "are on" + if self.join else "entries as they are written") + + def open_entry(self): + """THIS entry as its own lines, inside a screen that is otherwise + joined — or joined again. + + `z` is the whole answer at once, which is what makes ten traces + comparable; this is the one of them you have decided to read. + Several can be open, because comparing two is the next thing + after picking them out of the ten — and `z` builds the rendering + afresh, so setting the whole screen again clears them.""" + if self._joined is None: + self.message = ("nothing is joined — `z` shows each multi-line " + "entry as one row") + return + if not self.source.lines: + self.message = "nothing here to open" + return + state = self._rerender(lambda: self._joined.open_row(self.cur)) + if state is None: + self.message = "this entry is one line — nothing is folded into it" + else: + self.message = ("this entry as its own lines · Z joins it again" + if state else "this entry as one row again") # -- opening def leave_for_the_log(self): @@ -2037,6 +2119,10 @@ def status(self): z a multi-line entry as ONE row — a stack trace beside the message that raised it, so ten of them can be compared at all. Nothing is hidden: h/l read along it + Z THIS entry back out of the join, as its own lines — the + one of the ten you picked. Again joins it, from any of + its lines; several can be open at once, and z sets the + whole screen again t the investigation's window — the tape stops at it rather than scrolling out of the period you are looking at. `from T to T`, or `none` @@ -2236,6 +2322,8 @@ def step(self, stdscr, key): self.set_window(stdscr) elif key == ord("z"): v.join_entries() + elif key == ord("Z"): + v.open_entry() elif key == ord("w"): v.toggle_wrap() elif key == KEY_TAB: