|
6 | 6 |
|
7 | 7 | from __future__ import annotations |
8 | 8 |
|
| 9 | +import contextlib |
| 10 | +import curses |
| 11 | +import sys |
9 | 12 | import typing as t |
10 | 13 | from dataclasses import dataclass, field |
11 | 14 |
|
@@ -189,3 +192,122 @@ def _draw_frame(self, lines: list[str], w: int, h: int) -> str: |
189 | 192 | line = lines[r] if r < len(lines) else "" |
190 | 193 | body.append(f"|{line.ljust(w, self.fill_char)}|") |
191 | 194 | return "\n".join([border, *body, border]) |
| 195 | + |
| 196 | + def display(self) -> None: |
| 197 | + """Display frame in interactive scrollable curses viewer. |
| 198 | +
|
| 199 | + Opens a full-screen terminal viewer with scrolling support for |
| 200 | + exploring large frame content interactively. |
| 201 | +
|
| 202 | + Controls |
| 203 | + -------- |
| 204 | + Navigation: |
| 205 | + - Arrow keys: Scroll up/down/left/right |
| 206 | + - w/a/s/d: Scroll up/left/down/right |
| 207 | + - k/h/j/l (vim): Scroll up/left/down/right |
| 208 | + - PgUp/PgDn: Page up/down |
| 209 | + - Home/End: Jump to top/bottom |
| 210 | +
|
| 211 | + Exit: |
| 212 | + - q: Quit |
| 213 | + - Esc: Quit |
| 214 | + - Ctrl-C: Quit |
| 215 | +
|
| 216 | + Raises |
| 217 | + ------ |
| 218 | + RuntimeError |
| 219 | + If stdout is not a TTY (not an interactive terminal). |
| 220 | +
|
| 221 | + Examples |
| 222 | + -------- |
| 223 | + >>> pane = session.active_window.active_pane |
| 224 | + >>> frame = pane.capture_frame() |
| 225 | + >>> frame.display() # Opens interactive viewer # doctest: +SKIP |
| 226 | + """ |
| 227 | + if not sys.stdout.isatty(): |
| 228 | + msg = "display() requires an interactive terminal" |
| 229 | + raise RuntimeError(msg) |
| 230 | + |
| 231 | + curses.wrapper(self._curses_display) |
| 232 | + |
| 233 | + def _curses_display(self, stdscr: curses.window) -> None: |
| 234 | + """Curses main loop for interactive display. |
| 235 | +
|
| 236 | + Parameters |
| 237 | + ---------- |
| 238 | + stdscr : curses.window |
| 239 | + The curses standard screen window. |
| 240 | + """ |
| 241 | + curses.curs_set(0) # Hide cursor |
| 242 | + |
| 243 | + # Render full frame once |
| 244 | + rendered = self.render() |
| 245 | + lines = rendered.split("\n") |
| 246 | + |
| 247 | + # Scroll state |
| 248 | + scroll_y = 0 |
| 249 | + scroll_x = 0 |
| 250 | + |
| 251 | + while True: |
| 252 | + stdscr.clear() |
| 253 | + max_y, max_x = stdscr.getmaxyx() |
| 254 | + |
| 255 | + # Calculate scroll bounds |
| 256 | + max_scroll_y = max(0, len(lines) - max_y + 1) |
| 257 | + max_scroll_x = max(0, max(len(line) for line in lines) - max_x) |
| 258 | + |
| 259 | + # Clamp scroll position |
| 260 | + scroll_y = max(0, min(scroll_y, max_scroll_y)) |
| 261 | + scroll_x = max(0, min(scroll_x, max_scroll_x)) |
| 262 | + |
| 263 | + # Draw visible portion |
| 264 | + for i, line in enumerate(lines[scroll_y : scroll_y + max_y - 1]): |
| 265 | + if i >= max_y - 1: |
| 266 | + break |
| 267 | + display_line = line[scroll_x : scroll_x + max_x] |
| 268 | + with contextlib.suppress(curses.error): |
| 269 | + stdscr.addstr(i, 0, display_line) |
| 270 | + |
| 271 | + # Status line |
| 272 | + status = ( |
| 273 | + f" [{scroll_y + 1}/{len(lines)}] " |
| 274 | + f"{self.content_width}x{self.content_height} | q:quit " |
| 275 | + ) |
| 276 | + with contextlib.suppress(curses.error): |
| 277 | + stdscr.addstr(max_y - 1, 0, status[: max_x - 1], curses.A_REVERSE) |
| 278 | + |
| 279 | + stdscr.refresh() |
| 280 | + |
| 281 | + # Handle input |
| 282 | + try: |
| 283 | + key = stdscr.getch() |
| 284 | + except KeyboardInterrupt: |
| 285 | + break |
| 286 | + |
| 287 | + # Exit keys |
| 288 | + if key in (ord("q"), 27): # q or Esc |
| 289 | + break |
| 290 | + |
| 291 | + # Vertical navigation |
| 292 | + if key in (curses.KEY_UP, ord("w"), ord("k")): |
| 293 | + scroll_y = max(0, scroll_y - 1) |
| 294 | + elif key in (curses.KEY_DOWN, ord("s"), ord("j")): |
| 295 | + scroll_y = min(max_scroll_y, scroll_y + 1) |
| 296 | + |
| 297 | + # Horizontal navigation |
| 298 | + elif key in (curses.KEY_LEFT, ord("a"), ord("h")): |
| 299 | + scroll_x = max(0, scroll_x - 1) |
| 300 | + elif key in (curses.KEY_RIGHT, ord("d"), ord("l")): |
| 301 | + scroll_x = min(max_scroll_x, scroll_x + 1) |
| 302 | + |
| 303 | + # Page navigation |
| 304 | + elif key == curses.KEY_PPAGE: # Page Up |
| 305 | + scroll_y = max(0, scroll_y - (max_y - 2)) |
| 306 | + elif key == curses.KEY_NPAGE: # Page Down |
| 307 | + scroll_y = min(max_scroll_y, scroll_y + (max_y - 2)) |
| 308 | + |
| 309 | + # Jump navigation |
| 310 | + elif key == curses.KEY_HOME: |
| 311 | + scroll_y = 0 |
| 312 | + elif key == curses.KEY_END: |
| 313 | + scroll_y = max_scroll_y |
0 commit comments