Rewrite output to use Buffer for native scrolling
FormattedTextControl doesn't support scrolling - always renders from top. Switch to Buffer + BufferControl which has proper scroll support: - Output buffer uses read-only Buffer with cursor at end - Tab/Shift-Tab to switch focus between output and input - Arrow keys scroll when output is focused - Scrollbar margin shows position - Auto-scroll on new content (cursor stays at end) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f58197c83f
commit
fbb6c28ab7
1 changed files with 38 additions and 62 deletions
|
|
@ -85,50 +85,42 @@ STYLE = Style.from_dict({
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class OutputBuffer:
|
class OutputBuffer:
|
||||||
"""Manages scrolling output history."""
|
"""Manages scrolling output history using a text Buffer."""
|
||||||
|
|
||||||
def __init__(self, max_lines: int = 1000):
|
def __init__(self, max_lines: int = 1000):
|
||||||
self.lines: List[tuple] = [] # (style_class, text)
|
|
||||||
self.max_lines = max_lines
|
self.max_lines = max_lines
|
||||||
|
self._lines: List[str] = []
|
||||||
|
# Create a read-only buffer for display
|
||||||
|
self.buffer = Buffer(read_only=True, name="output")
|
||||||
|
|
||||||
def append(self, text: str, style: str = "output"):
|
def append(self, text: str, style: str = "output"):
|
||||||
"""Add a line to output."""
|
"""Add a line to output."""
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
self.lines.append((style, f"[{timestamp}] {text}"))
|
self._lines.append(f"[{timestamp}] {text}")
|
||||||
if len(self.lines) > self.max_lines:
|
self._update_buffer()
|
||||||
self.lines = self.lines[-self.max_lines:]
|
|
||||||
|
|
||||||
def append_raw(self, text: str, style: str = "output"):
|
def append_raw(self, text: str, style: str = "output"):
|
||||||
"""Add without timestamp."""
|
"""Add without timestamp."""
|
||||||
self.lines.append((style, text))
|
self._lines.append(text)
|
||||||
if len(self.lines) > self.max_lines:
|
self._update_buffer()
|
||||||
self.lines = self.lines[-self.max_lines:]
|
|
||||||
|
|
||||||
def get_formatted_text(self, scroll_offset: int = 0) -> FormattedText:
|
def _update_buffer(self):
|
||||||
"""Get formatted text for display.
|
"""Update the buffer content and scroll to bottom."""
|
||||||
|
# Trim if needed
|
||||||
|
if len(self._lines) > self.max_lines:
|
||||||
|
self._lines = self._lines[-self.max_lines:]
|
||||||
|
|
||||||
scroll_offset: 0 = show bottom, positive = scroll up N lines
|
# Update buffer text
|
||||||
"""
|
text = "\n".join(self._lines)
|
||||||
result = []
|
self.buffer.set_document(
|
||||||
|
Document(text=text, cursor_position=len(text)),
|
||||||
if scroll_offset == 0:
|
bypass_readonly=True
|
||||||
# Show all lines (newest at bottom)
|
)
|
||||||
for style, text in self.lines:
|
|
||||||
result.append((f"class:{style}", text))
|
|
||||||
result.append(("", "\n"))
|
|
||||||
else:
|
|
||||||
# Show lines up to scroll_offset from end
|
|
||||||
end_idx = len(self.lines) - scroll_offset
|
|
||||||
if end_idx > 0:
|
|
||||||
for style, text in self.lines[:end_idx]:
|
|
||||||
result.append((f"class:{style}", text))
|
|
||||||
result.append(("", "\n"))
|
|
||||||
|
|
||||||
return FormattedText(result)
|
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear output."""
|
"""Clear output."""
|
||||||
self.lines.clear()
|
self._lines.clear()
|
||||||
|
self.buffer.set_document(Document(text=""), bypass_readonly=True)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -198,45 +190,29 @@ class TUIConsole:
|
||||||
def handle_ctrl_l(event):
|
def handle_ctrl_l(event):
|
||||||
"""Handle Ctrl+L - clear output."""
|
"""Handle Ctrl+L - clear output."""
|
||||||
self.output.clear()
|
self.output.clear()
|
||||||
self.scroll_offset = 0
|
|
||||||
|
|
||||||
# Track scroll offset (0 = bottom, positive = lines from bottom)
|
@kb.add("tab")
|
||||||
self.scroll_offset = 0
|
def handle_tab(event):
|
||||||
|
"""Switch focus between output and input."""
|
||||||
|
event.app.layout.focus_next()
|
||||||
|
|
||||||
@kb.add("pageup")
|
@kb.add("s-tab")
|
||||||
def handle_page_up(event):
|
def handle_shift_tab(event):
|
||||||
"""Scroll output up."""
|
"""Switch focus between output and input."""
|
||||||
max_offset = max(0, len(self.output.lines) - 5)
|
event.app.layout.focus_previous()
|
||||||
self.scroll_offset = min(self.scroll_offset + 10, max_offset)
|
|
||||||
|
|
||||||
@kb.add("pagedown")
|
# Output uses BufferControl for native scrolling
|
||||||
def handle_page_down(event):
|
output_control = BufferControl(
|
||||||
"""Scroll output down."""
|
buffer=self.output.buffer,
|
||||||
self.scroll_offset = max(0, self.scroll_offset - 10)
|
focusable=True, # Allow focus for scrolling
|
||||||
|
include_default_input_processors=False,
|
||||||
@kb.add("end")
|
|
||||||
def handle_end(event):
|
|
||||||
"""Scroll to bottom."""
|
|
||||||
self.scroll_offset = 0
|
|
||||||
|
|
||||||
@kb.add("home")
|
|
||||||
def handle_home(event):
|
|
||||||
"""Scroll to top."""
|
|
||||||
self.scroll_offset = max(0, len(self.output.lines) - 5)
|
|
||||||
|
|
||||||
# Output control - uses scroll_offset to show correct portion
|
|
||||||
def get_visible_output():
|
|
||||||
return self.output.get_formatted_text(self.scroll_offset)
|
|
||||||
|
|
||||||
output_control = FormattedTextControl(
|
|
||||||
text=get_visible_output,
|
|
||||||
focusable=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Output window - takes all available space
|
# Output window - takes all available space, scrolls with cursor
|
||||||
self.output_window = Window(
|
self.output_window = Window(
|
||||||
content=output_control,
|
content=output_control,
|
||||||
wrap_lines=True,
|
wrap_lines=True,
|
||||||
|
right_margins=[ScrollbarMargin(display_arrows=True)],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Separator line with status (shows scroll hint if not at bottom)
|
# Separator line with status (shows scroll hint if not at bottom)
|
||||||
|
|
@ -447,8 +423,8 @@ class TUIConsole:
|
||||||
self.print_raw(" Ctrl+C / Ctrl+D Quit", "output.dim")
|
self.print_raw(" Ctrl+C / Ctrl+D Quit", "output.dim")
|
||||||
self.print_raw(" Ctrl+L Clear output", "output.dim")
|
self.print_raw(" Ctrl+L Clear output", "output.dim")
|
||||||
self.print_raw(" Up/Down Command history", "output.dim")
|
self.print_raw(" Up/Down Command history", "output.dim")
|
||||||
self.print_raw(" Page Up/Down Scroll output history", "output.dim")
|
self.print_raw(" Tab Switch focus (input/output)", "output.dim")
|
||||||
self.print_raw(" Home/End Jump to top/bottom", "output.dim")
|
self.print_raw(" Arrow keys Scroll when output focused", "output.dim")
|
||||||
|
|
||||||
async def _cmd_status(self, args: str):
|
async def _cmd_status(self, args: str):
|
||||||
"""Show status."""
|
"""Show status."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue