Skip to content

Commit 90010ab

Browse files
committed
TextFrame(feat[display]): Add interactive curses viewer
why: Enable interactive exploration of large frame content in terminal what: - Add display() method with TTY detection - Add _curses_display() with scrolling support - Navigation: arrows, WASD, vim keys (hjkl) - Page navigation: PgUp/PgDn, Home/End - Exit: q, Esc, Ctrl-C
1 parent 3d16cee commit 90010ab

File tree

1 file changed

+122
-0
lines changed

1 file changed

+122
-0
lines changed

src/libtmux/textframe/core.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
from __future__ import annotations
88

9+
import contextlib
10+
import curses
11+
import sys
912
import typing as t
1013
from dataclasses import dataclass, field
1114

@@ -189,3 +192,122 @@ def _draw_frame(self, lines: list[str], w: int, h: int) -> str:
189192
line = lines[r] if r < len(lines) else ""
190193
body.append(f"|{line.ljust(w, self.fill_char)}|")
191194
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

Comments
 (0)