From a61386d41c4a332df30e3ff80c2511c080d2b08f Mon Sep 17 00:00:00 2001 From: TGPSKI Date: Thu, 6 Aug 2026 13:06:10 -0700 Subject: [PATCH 1/2] Re-vendor pane: grids.py, RowCursor, chart geometry Mechanical sync of the drawing layer from pane@grids-and-cursor. The only behavioural change reaching this repo is bar_chart's optional geometry= out-parameter, which is additive and unused here. --- src/adherence/tui/__init__.py | 5 +- src/adherence/tui/charts.py | 21 ++++- src/adherence/tui/grids.py | 146 ++++++++++++++++++++++++++++++++++ src/adherence/tui/interact.py | 52 ++++++++++++ 4 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 src/adherence/tui/grids.py diff --git a/src/adherence/tui/__init__.py b/src/adherence/tui/__init__.py index 8cc9897..c3dcfe8 100644 --- a/src/adherence/tui/__init__.py +++ b/src/adherence/tui/__init__.py @@ -1,7 +1,8 @@ """Vendored curses-TUI primitives (stdlib only). -Source: https://github.com/TGPSKI/pane @ a51e682 -(src/pane: framework.py, charts.py, fmt.py, windows.py, interact.py), +Source: https://github.com/TGPSKI/pane @ 9099e39+dirty +(src/pane: framework.py, charts.py, fmt.py, windows.py, interact.py, +grids.py), copied verbatim so this repository runs from a fresh clone with no external path dependency. Upstream owns the API; keep edits there and re-vendor with pane's tools/vendor.sh rather than diverging here. diff --git a/src/adherence/tui/charts.py b/src/adherence/tui/charts.py index 3003669..bd57d24 100644 --- a/src/adherence/tui/charts.py +++ b/src/adherence/tui/charts.py @@ -46,7 +46,7 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, label_every=1, label_row_offset=1, label_pad=2, half_blocks=False, label_fit=False, bin_unit="", clip_ratio=None, clip_min_bars=5, clip_max_frac=0.25, - no_data_text="no data available"): + no_data_text="no data available", geometry=None): """Draw a vertical bar chart of series from row `top`; return next row. put(y, x, text, attr) is the caller's bounds-checked writer. @@ -56,6 +56,14 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, clip_ratio: cap the y-axis at ratio x the median non-zero bucket so a lone outlier can't flatten the rest. Over-cap bars run to the top row and are labelled there with their real value + '↑'. None disables it. + geometry: an empty dict, filled in with the layout actually used — + plot_x, bar_w, gap, slot, span, n, binned, plot_top, plot_h, axis_row, + label_row, max_val, series. A caller that wants to annotate a chart + (a rule under one bucket, a strip aligned to the same columns) needs + bar x positions, and binning/shrinking means it cannot derive those + positions itself. Reporting what was drawn — rather than offering a + second function that recomputes it — is what stops an annotation from + sliding off the bar it describes. """ A_DIM = curses_mod.A_DIM y = top @@ -64,6 +72,10 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, put(y, 1, title, title_attr) y += 1 put(y, 3, no_data_text, A_DIM) + if geometry is not None: + geometry.update(n=0, series=[], binned=1, plot_top=y, plot_h=0, + plot_x=8, bar_w=0, gap=0, slot=0, span=0, + axis_row=y, label_row=y, max_val=0, clipped=set()) return y + 1 axis_w = 7 @@ -141,6 +153,13 @@ def bar_chart(put, curses_mod, top, series, plot_h, max_x, *, span = min(n * bar_w + (n - 1) * gap, avail) put(y + plot_h, axis_w, "└" + "─" * span, axis_attr) + if geometry is not None: + geometry.update(n=n, series=series, binned=binned, plot_top=y, + plot_h=plot_h, plot_x=plot_x, bar_w=bar_w, gap=gap, + slot=slot, span=span, axis_row=y + plot_h, + label_row=y + plot_h + label_row_offset, + max_val=max_val, clipped=clipped) + heights = [] for i, b in enumerate(series): count = b["count"] diff --git a/src/adherence/tui/grids.py b/src/adherence/tui/grids.py new file mode 100644 index 0000000..52e3746 --- /dev/null +++ b/src/adherence/tui/grids.py @@ -0,0 +1,146 @@ +"""Row x column intensity grids. Stdlib only. + +The complement to `charts.bar_chart`: a bar chart answers "how much, over +time, for one series", a heatmap answers "which of these many series, on +which day". Once a table has more than a couple of dozen rows and one row +per event, reading it as text stops working — the eye needs the grid. + +Domain-free by the same admission test as the rest of the package: this +module knows about intensities, glyphs and columns. What a row is, what +makes a cell hot, and which cells deserve a different glyph are all the +caller's business, supplied as values and callbacks. +""" +from __future__ import annotations + +#: Default 5-step intensity ramp, blank -> full. Chosen so that "nothing +#: happened" is genuinely empty space rather than a dim character: a grid +#: of mostly-zero cells should read as a few marks on a page, not as +#: texture you have to look past. +RAMP = " ·▪▮█" + + +def ramp_glyph(value, ramp=RAMP): + """Map a 0..1 intensity onto a ramp character. + + Anything above 0 gets at least the first non-blank step, so a small + but real value can never render as absent — the distinction between + "zero" and "nearly zero" is the one a heatmap most often has to make. + """ + if value is None or value <= 0: + return ramp[0] + if value >= 1: + return ramp[-1] + steps = len(ramp) - 1 + return ramp[max(1, min(steps, int(value * steps) + 1))] + + +#: Scrollbar track and thumb. Box-drawing rather than blocks so the bar +#: reads as chrome at the edge of a pane and not as data in it. +TRACK, THUMB = "│", "█" + + +def scrollbar(put, curses_mod, top, height, x, total, offset, *, + attr=0, track_attr=None, track=TRACK, thumb=THUMB): + """Draw a vertical scrollbar at column x, from row `top`, `height` tall. + + total is the number of rows the list has; offset is the index of its + first visible row; `height` is how many are visible. Draws nothing when + everything fits — a scrollbar that is always full is furniture. + + The thumb is at least one cell tall however long the list is, because a + thumb rounded to zero is indistinguishable from no overflow at all, + which is the one thing the bar exists to say. + """ + if total <= height or height <= 0: + return + A_DIM = curses_mod.A_DIM + span = max(1, min(height, round(height * height / total))) + reach = max(1, total - height) + start = round((height - span) * min(offset, reach) / reach) + for i in range(height): + in_thumb = start <= i < start + span + put(top + i, x, thumb if in_thumb else track, + attr if in_thumb else (track_attr if track_attr is not None else A_DIM)) + + +def heatmap(put, curses_mod, top, max_x, *, rows, col_labels, + label_w=None, cell_w=1, gap=0, ramp=RAMP, + glyph_for=None, attr_for=None, label_attr=0, header_attr=0, + col_label_every=None, scroll=0, height=None, cursor=None, + cursor_attr=None, header_gap=0, no_data_text="no data available"): + """Draw an intensity grid from row `top`; return the row just below it. + + rows [(label, [intensity, ...])] — one entry per grid row, each + intensity a 0..1 float or None for "no observation". Rows + need not be the same length; short rows are left blank. + col_labels one label per column; drawn vertically-sparse on the header + row so long labels (dates) do not overwrite each other. + glyph_for (value, r, c) -> str, overriding the ramp. Whatever it + returns is the WHOLE cell, centred and clipped to cell_w — + unlike the ramp, which FILLS the cell with its glyph. A + caller marking a cell 'C' wants one C under the column + header, not cell_w of them: repeating it turns a five-wide + column into twenty-five characters and slides every column + after it out from under its own date. + attr_for (value, r, c) -> curses attr for the cell. + scroll first row index to draw; height caps how many are drawn. + cursor index of the row to highlight with cursor_attr. + header_gap blank rows between the column labels and the first row. A + sparse grid sits directly under its dates and reads as one + undifferentiated block; one blank row is the difference + between a header and a first data row. + + Columns are clipped from the left when the grid is wider than the + terminal: the newest column is the one that must survive, and dropping + the oldest is the only truncation that keeps "now" on screen. + """ + A_DIM = curses_mod.A_DIM + y = top + if not rows or not col_labels: + put(y, 3, no_data_text, A_DIM) + return y + 1 + + if label_w is None: + label_w = min(24, max(8, max(len(str(lbl)) for lbl, _ in rows))) + grid_x = 3 + label_w + 1 + slot = cell_w + gap + room = max(1, (max_x - grid_x - 2) // slot) + first_col = max(0, len(col_labels) - room) + cols = list(range(first_col, len(col_labels))) + + # Header: sparse column labels, spaced by how wide they actually are. + # Walked newest -> oldest so the last column always keeps its label, and + # each one is dropped rather than drawn if it would run into the label to + # its right. Clamping alone is not enough: pinning the final label inside + # the grid moves it left, on top of its neighbour, and two dates collide + # into one unreadable run ("0708-06"). + step = col_label_every or max(1, -(-(max(len(str(c)) for c in col_labels) + 1) // slot)) + rightmost = grid_x + len(cols) * slot + leftmost = rightmost + 1 + for i in range(len(cols) - 1, -1, -1): + if (len(cols) - 1 - i) % step: + continue + label = str(col_labels[cols[i]]) + x = min(grid_x + i * slot, rightmost - len(label)) + if x + len(label) >= leftmost: + continue + put(y, x, label, header_attr or A_DIM) + leftmost = x + y += 1 + header_gap + + visible = rows[scroll:scroll + height] if height else rows[scroll:] + for r_off, (label, values) in enumerate(visible): + r = scroll + r_off + row_attr = cursor_attr if (cursor is not None and r == cursor and + cursor_attr is not None) else label_attr + put(y, 3, str(label)[:label_w].ljust(label_w), row_attr) + for i, c in enumerate(cols): + value = values[c] if c < len(values) else None + if glyph_for: + text = str(glyph_for(value, r, c))[:cell_w].center(cell_w) + else: + text = ramp_glyph(value, ramp) * cell_w + attr = attr_for(value, r, c) if attr_for else 0 + put(y, grid_x + i * slot, text, attr) + y += 1 + return y diff --git a/src/adherence/tui/interact.py b/src/adherence/tui/interact.py index 7c79cda..6c4e546 100644 --- a/src/adherence/tui/interact.py +++ b/src/adherence/tui/interact.py @@ -67,3 +67,55 @@ def cycle(seq, current, step=1): return seq[(seq.index(current) + step) % len(seq)] except ValueError: return seq[0] + + +class RowCursor: + """A selected row index and the scroll offset that keeps it on screen. + + Scroll-only lists answer "what is here"; a cursor is what lets a list + answer "tell me more about *this* one", which is the basis of every + drill-down. The two numbers have to move together — an app that keeps + them apart eventually scrolls the selection off screen and then acts + on a row the operator cannot see. + + Knows nothing about rows: `total` and `page` are supplied per call, so + one cursor survives a list whose length changes under it (a filter + typed into `/`, a sort that drops empty entries). + """ + + def __init__(self, index=0, scroll=0): + self.index = index + self.scroll = scroll + + def clamp(self, total, page): + """Pull index and scroll back into range for the list as it is now.""" + if total <= 0: + self.index = self.scroll = 0 + return self + page = max(1, page) + self.index = max(0, min(self.index, total - 1)) + self.scroll = max(0, min(self.scroll, max(0, total - page))) + if self.index < self.scroll: + self.scroll = self.index + elif self.index >= self.scroll + page: + self.scroll = self.index - page + 1 + return self + + def move(self, delta, total, page): + """Move the selection by delta rows, scrolling to follow it.""" + self.index += delta + return self.clamp(total, page) + + def to(self, index, total, page): + self.index = index + return self.clamp(total, page) + + def home(self, total, page): + return self.to(0, total, page) + + def end(self, total, page): + return self.to(total - 1, total, page) + + def reset(self): + self.index = self.scroll = 0 + return self From 13519f54ef5987f0492d016c3f76c491b8b7a1df Mon Sep 17 00:00:00 2001 From: TGPSKI Date: Sun, 9 Aug 2026 01:18:09 -0700 Subject: [PATCH 2/2] Re-vendor pane v0.2.0: diverging_bars, peak-label fix Mechanical sync of the drawing layer from pane@9e80e94 (tag v0.2.0), replacing the previous copy taken mid-stream from a dirty tree at c098bd2. Upstream commits landing here: c098bd2 (grids.diverging_bars, signed bars around a zero line) and ffba597 (bar_chart no longer lets a value label overwrite the peak marker it sits on). Neither API has a call site in this repo, so nothing observable changes. pane's tools/check-vendor.sh reports byte-identity across all four vendor sites; make check is 25/25. --- src/adherence/tui/__init__.py | 2 +- src/adherence/tui/charts.py | 25 ++++++++---- src/adherence/tui/grids.py | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/adherence/tui/__init__.py b/src/adherence/tui/__init__.py index c3dcfe8..38e2ac2 100644 --- a/src/adherence/tui/__init__.py +++ b/src/adherence/tui/__init__.py @@ -1,6 +1,6 @@ """Vendored curses-TUI primitives (stdlib only). -Source: https://github.com/TGPSKI/pane @ 9099e39+dirty +Source: https://github.com/TGPSKI/pane @ 9e80e94 (src/pane: framework.py, charts.py, fmt.py, windows.py, interact.py, grids.py), copied verbatim so this repository runs from a fresh clone with no diff --git a/src/adherence/tui/charts.py b/src/adherence/tui/charts.py index bd57d24..5668aa2 100644 --- a/src/adherence/tui/charts.py +++ b/src/adherence/tui/charts.py @@ -250,19 +250,30 @@ def _touches_top(j): else: if _touches_top(i): top_free = max(top_free, x + bar_w) - if peak_attr is not None and b.get("peak") and h_eff < plot_h: + marked = peak_attr is not None and b.get("peak") and h_eff < plot_h + if marked: put(y + plot_h - 1 - h_eff, x, "▲" * min(bar_w, 1), peak_attr) # value above bar — only when there's a clear row above it, so the # tallest bar's label never lands on the title/axis-max line. + # + # A marked bar keeps its ▲ and gives up its label unless the + # value also fits beside it. Written the other way round, the + # label landed on the same cell and silently erased the marker, + # so a chart could announce "▲ marks this day" and show none. if value_labels and count > 0 and h_eff < plot_h: vs = fmt(count) - # label_fit: write the full value only when it fits before the - # next bar; a truncated "1.3k"->"1" is worse than no label. - if not label_fit: - put(y + plot_h - 1 - h_eff, x, vs[:slot], A_DIM) - elif len(vs) <= slot - 1: - put(y + plot_h - 1 - h_eff, x, vs, A_DIM) + row, col = y + plot_h - 1 - h_eff, x + if marked: + col += 1 + if len(vs) + 1 > slot - 1: + vs = "" + if not vs: + pass + elif not label_fit: + put(row, col, vs[: max(0, slot - (1 if marked else 0))], A_DIM) + elif len(vs) <= slot - 1 - (1 if marked else 0): + put(row, col, vs, A_DIM) if not label_fit and (label_every <= 1 or i % label_every == 0 or i == n - 1): diff --git a/src/adherence/tui/grids.py b/src/adherence/tui/grids.py index 52e3746..b5659d3 100644 --- a/src/adherence/tui/grids.py +++ b/src/adherence/tui/grids.py @@ -63,6 +63,82 @@ def scrollbar(put, curses_mod, top, height, x, total, offset, *, attr if in_thumb else (track_attr if track_attr is not None else A_DIM)) +def diverging_bars(put, curses_mod, top, series, height, max_x, *, + title=None, title_attr=0, axis_attr=0, + pos_attr=0, neg_attr=0, fmt=str, label_every=None, + label_row_offset=1, bar_w=None, max_bar_w=6, gap=1, + right_margin=2, zero_glyph="\u2500", + no_data_text="no data available"): + """Signed bars above and below a zero line; return the row below. + + `bar_chart` measures level and cannot draw a negative, because its + heights grow out of a floor at zero. A change, a delta, a derivative — + anything whose sign is the point — needs the axis in the middle + instead, or the reader has to infer direction out of a colour and take + it on trust. + + series items are {'label', 'value'} where value may be negative. The + zero line always renders, including for an all-positive series, so + "nothing went down" is visible rather than merely absent. + """ + A_DIM = curses_mod.A_DIM + y = top + if title: + put(y, 1, title, title_attr) + y += 1 + if not series: + put(y, 3, no_data_text, A_DIM) + return y + 1 + + axis_w = 7 + plot_x = axis_w + 1 + avail = max(1, max_x - plot_x - right_margin) + n = len(series) + if bar_w is None: + bar_w = max(1, min(max_bar_w, avail // n - gap)) + if n * (bar_w + gap) > avail: + gap = 0 + bar_w = max(1, avail // n) + slot = bar_w + gap + + peak = max((abs(b["value"]) for b in series), default=0) + # Split the height either side of the zero line, which owns its own row. + half = max(1, (height - 1) // 2) + zero_row = y + half + + for i, half_label in ((0, peak), (half * 2, -peak)): + label = fmt(half_label) + put(y + i, max(0, axis_w - len(label)), label, A_DIM) + put(zero_row, max(0, axis_w - 1), "0", A_DIM) + span = min(n * bar_w + (n - 1) * gap, avail) + put(zero_row, axis_w, "\u253c" + zero_glyph * span, axis_attr) + + for i, b in enumerate(series): + value = b["value"] + x = plot_x + i * slot + if not value or not peak: + continue + cells = max(1, round(abs(value) / peak * half)) + attr = pos_attr if value > 0 else neg_attr + for c in range(cells): + row = zero_row - 1 - c if value > 0 else zero_row + 1 + c + if y <= row <= zero_row + half: + put(row, x, "\u2588" * bar_w, attr) + + step = label_every or max(1, -(-(max(len(str(b["label"])) for b in series) + 1) // slot)) + label_row = zero_row + half + label_row_offset + leftmost = plot_x + span + 1 + for i in range(n - 1, -1, -1): + if (n - 1 - i) % step: + continue + label = str(series[i]["label"]) + x = min(plot_x + i * slot, plot_x + span - len(label)) + if x + len(label) < leftmost: + put(label_row, x, label, A_DIM) + leftmost = x + return label_row + 1 + + def heatmap(put, curses_mod, top, max_x, *, rows, col_labels, label_w=None, cell_w=1, gap=0, ramp=RAMP, glyph_for=None, attr_for=None, label_attr=0, header_attr=0,