From e605290edcd7b795c30d20d07b0bed101bdab020 Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Fri, 19 Dec 2025 12:21:18 +0100 Subject: [PATCH 01/14] Add SelectableLog widget with text selection and clipboard - Implements a RichLog replacement supporting mouse selection, Ctrl+A, and Ctrl+C to copy selected text to clipboard. - Preserves ANSI color codes, strips control sequences, and trims buffer to max_lines. - Adds Selection dataclass for text range representation. - Includes integration and unit tests for SelectableLog behavior. - Adds pyperclip as a dependency for clipboard support. --- campers/logging/handlers.py | 36 +- campers/tui/app.py | 62 +- campers/tui/widgets/__init__.py | 6 + campers/tui/widgets/selectable_log.py | 272 ++++++++ campers/tui/widgets/selection.py | 39 ++ pyproject.toml | 1 + .../steps/tui_selectable_log_steps.py | 635 ++++++++++++++++++ .../features/tui_selectable_log.feature | 75 +++ tests/unit/test_selectable_log.py | 236 +++++++ uv.lock | 13 +- 10 files changed, 1334 insertions(+), 41 deletions(-) create mode 100644 campers/tui/widgets/__init__.py create mode 100644 campers/tui/widgets/selectable_log.py create mode 100644 campers/tui/widgets/selection.py create mode 100644 tests/integration/features/steps/tui_selectable_log_steps.py create mode 100644 tests/integration/features/tui_selectable_log.feature create mode 100644 tests/unit/test_selectable_log.py diff --git a/campers/logging/handlers.py b/campers/logging/handlers.py index 21a803c..57a9447 100644 --- a/campers/logging/handlers.py +++ b/campers/logging/handlers.py @@ -2,10 +2,9 @@ import logging import threading -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol from textual.message import Message -from textual.widgets import RichLog if TYPE_CHECKING: from campers.tui import CampersTUI @@ -13,6 +12,23 @@ logger = logging.getLogger(__name__) +class LogWidget(Protocol): + """Protocol for log widget objects. + + Defines the interface required for widgets to work with TuiLogHandler. + """ + + def write(self, content: str) -> None: + """Write content to the log widget. + + Parameters + ---------- + content : str + Content to write to the log + """ + ... + + class TuiLogMessage(Message): """Message delivering a log line to the TUI log widget.""" @@ -22,32 +38,32 @@ def __init__(self, text: str) -> None: class TuiLogHandler(logging.Handler): - """Logging handler that writes to a Textual RichLog widget. + """Logging handler that writes to a Textual log widget. Parameters ---------- app : CampersTUI Textual app instance - log_widget : RichLog - RichLog widget to write to + log_widget : LogWidget + Log widget to write to (RichLog or SelectableLog) Attributes ---------- app : CampersTUI Textual app instance - log_widget : RichLog - RichLog widget to write to + log_widget : LogWidget + Log widget to write to (RichLog or SelectableLog) """ - def __init__(self, app: "CampersTUI", log_widget: RichLog) -> None: + def __init__(self, app: "CampersTUI", log_widget: LogWidget) -> None: """Initialize TuiLogHandler. Parameters ---------- app : CampersTUI Textual app instance - log_widget : RichLog - RichLog widget to write to + log_widget : LogWidget + Log widget to write to (RichLog or SelectableLog) """ super().__init__() self.app = app diff --git a/campers/tui/app.py b/campers/tui/app.py index 703e159..9fe7243 100644 --- a/campers/tui/app.py +++ b/campers/tui/app.py @@ -14,7 +14,7 @@ from textual import events from textual.app import App, ComposeResult from textual.containers import Container -from textual.widgets import RichLog, Static +from textual.widgets import Static from campers.constants import ( CTRL_C_DOUBLE_PRESS_THRESHOLD_SECONDS, @@ -26,11 +26,12 @@ ) from campers.logging import StreamFormatter, TuiLogHandler, TuiLogMessage from campers.providers.exceptions import ProviderCredentialsError +from campers.tui import widgets from campers.tui.exit_modal import ExitModal from campers.tui.instance_overview_widget import InstanceOverviewWidget from campers.tui.styling import TUI_CSS from campers.tui.terminal import detect_terminal_background -from campers.tui.widgets import WidgetID +from campers.tui.widgets.selectable_log import SelectableLog if TYPE_CHECKING: from campers import Campers @@ -101,7 +102,7 @@ def __init__( self.worker_exit_code = 0 self.instance_start_time: datetime | None = None self.last_ctrl_c_time: float = 0.0 - self.log_widget: RichLog | None = None + self.log_widget: SelectableLog | None = None self.fatal_error_message: str | None = None self._running = True self._thread_id = threading.get_ident() @@ -119,25 +120,25 @@ def compose(self) -> ComposeResult: """ with Container(id="status-panel"): yield InstanceOverviewWidget(self.campers) - yield Static("SSH: loading...", id=WidgetID.SSH) - yield Static("Status: launching...", id=WidgetID.STATUS) - yield Static("Uptime: 0s", id=WidgetID.UPTIME) - yield Static("Instance Type: loading...", id=WidgetID.INSTANCE_TYPE) - yield Static("Region: loading...", id=WidgetID.REGION) - yield Static("Camp Name: loading...", id=WidgetID.CAMP_NAME) - yield Static("Command: loading...", id=WidgetID.COMMAND) - yield Static("File sync: Not syncing", id=WidgetID.MUTAGEN) - yield Static("Port forwarding: none", id=WidgetID.PORTFORWARD) - yield Static("", id=WidgetID.PUBLIC_PORTS, classes="hidden") + yield Static("SSH: loading...", id=widgets.WidgetID.SSH) + yield Static("Status: launching...", id=widgets.WidgetID.STATUS) + yield Static("Uptime: 0s", id=widgets.WidgetID.UPTIME) + yield Static("Instance Type: loading...", id=widgets.WidgetID.INSTANCE_TYPE) + yield Static("Region: loading...", id=widgets.WidgetID.REGION) + yield Static("Camp Name: loading...", id=widgets.WidgetID.CAMP_NAME) + yield Static("Command: loading...", id=widgets.WidgetID.COMMAND) + yield Static("File sync: Not syncing", id=widgets.WidgetID.MUTAGEN) + yield Static("Port forwarding: none", id=widgets.WidgetID.PORTFORWARD) + yield Static("", id=widgets.WidgetID.PUBLIC_PORTS, classes="hidden") with Container(id="log-panel"): - yield RichLog(markup=True) + yield SelectableLog() def on_mount(self) -> None: """Handle mount event - setup logging, start worker, and timer.""" root_logger = logging.getLogger() self.original_handlers = root_logger.handlers[:] - log_widget = self.query_one(RichLog) + log_widget = self.query_one(SelectableLog) self.log_widget = log_widget tui_handler = TuiLogHandler(self, log_widget) tui_handler.setFormatter(StreamFormatter("%(message)s")) @@ -232,7 +233,7 @@ def update_uptime(self) -> None: uptime_str = f"{seconds}s" try: - self.query_one(f"#{WidgetID.UPTIME}").update(f"Uptime: {uptime_str}") + self.query_one(f"#{widgets.WidgetID.UPTIME}").update(f"Uptime: {uptime_str}") except (ValueError, AttributeError) as e: logging.error("Failed to update uptime widget: %s", e) @@ -248,7 +249,7 @@ def update_status(self, payload: dict[str, Any]) -> None: status = payload["status"] try: - self.query_one(f"#{WidgetID.STATUS}").update(f"Status: {status}") + self.query_one(f"#{widgets.WidgetID.STATUS}").update(f"Status: {status}") except (ValueError, AttributeError) as e: logging.error("Failed to update status widget: %s", e) @@ -279,7 +280,7 @@ def update_mutagen_status(self, payload: dict[str, Any]) -> None: display_text = f"File sync: {state}" try: - self.query_one(f"#{WidgetID.MUTAGEN}").update(display_text) + self.query_one(f"#{widgets.WidgetID.MUTAGEN}").update(display_text) except (ValueError, AttributeError) as e: logging.error("Failed to update mutagen widget: %s", e) @@ -306,7 +307,7 @@ def update_portforward_status(self, payload: dict[str, Any]) -> None: text = "Port forwarding: none" try: - self.query_one(f"#{WidgetID.PORTFORWARD}").update(text) + self.query_one(f"#{widgets.WidgetID.PORTFORWARD}").update(text) except (ValueError, AttributeError) as e: logging.error("Failed to update portforward widget: %s", e) @@ -334,7 +335,7 @@ def update_from_config(self, config: dict[str, Any]) -> None: if "instance_type" in config: try: - self.query_one(f"#{WidgetID.INSTANCE_TYPE}").update( + self.query_one(f"#{widgets.WidgetID.INSTANCE_TYPE}").update( f"Instance Type: {config['instance_type']}" ) except (ValueError, AttributeError, RuntimeError) as e: @@ -342,26 +343,27 @@ def update_from_config(self, config: dict[str, Any]) -> None: if "region" in config: try: - self.query_one(f"#{WidgetID.REGION}").update(f"Region: {config['region']}") + self.query_one(f"#{widgets.WidgetID.REGION}").update(f"Region: {config['region']}") except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update region widget: %s", e) camp_name = config.get("camp_name", "ad-hoc") try: - self.query_one(f"#{WidgetID.CAMP_NAME}").update(f"Camp Name: {camp_name}") + self.query_one(f"#{widgets.WidgetID.CAMP_NAME}").update(f"Camp Name: {camp_name}") except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update camp name widget: %s", e) if "command" in config: try: - self.query_one(f"#{WidgetID.COMMAND}").update(f"Command: {config['command']}") + cmd = config["command"] + self.query_one(f"#{widgets.WidgetID.COMMAND}").update(f"Command: {cmd}") except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update command widget: %s", e) public_ports = config.get("public_ports", []) try: - public_ports_widget = self.query_one(f"#{WidgetID.PUBLIC_PORTS}") + public_ports_widget = self.query_one(f"#{widgets.WidgetID.PUBLIC_PORTS}") if public_ports: instance_details = self.campers._resources.get("instance_details", {}) public_ip = instance_details.get("public_ip") @@ -388,7 +390,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None: """ if "state" in details: try: - self.query_one(f"#{WidgetID.STATUS}").update(f"Status: {details['state']}") + self.query_one(f"#{widgets.WidgetID.STATUS}").update(f"Status: {details['state']}") except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update status widget: %s", e) @@ -404,7 +406,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None: ssh_username = details.get("ssh_username", DEFAULT_SSH_USERNAME) key_file = details.get("key_file", "key.pem") ssh_string = f"ssh -o IdentitiesOnly=yes -i {key_file} {ssh_username}@{public_ip}" - self.query_one(f"#{WidgetID.SSH}").update(f"SSH: {ssh_string}") + self.query_one(f"#{widgets.WidgetID.SSH}").update(f"SSH: {ssh_string}") except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update SSH widget: %s", e) @@ -416,7 +418,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None: protocol = "https" if port == 443 else "http" urls.append(f"{protocol}://{public_ip}:{port}") public_ports_text = f"Public IP: {public_ip} | URLs: " + ", ".join(urls) - public_ports_widget = self.query_one(f"#{WidgetID.PUBLIC_PORTS}") + public_ports_widget = self.query_one(f"#{widgets.WidgetID.PUBLIC_PORTS}") public_ports_widget.update(public_ports_text) public_ports_widget.remove_class("hidden") except (ValueError, AttributeError, RuntimeError) as e: @@ -579,7 +581,7 @@ def on_key(self, event: events.Key) -> None: and (current_time - self.last_ctrl_c_time) < CTRL_C_DOUBLE_PRESS_THRESHOLD_SECONDS ): try: - log_widget = self.query_one(RichLog) + log_widget = self.query_one(SelectableLog) log_widget.write("[red]Force exit - skipping cleanup![/red]") except (ValueError, AttributeError, RuntimeError) as e: logger.debug("Failed to write to log widget during force exit: %s", e) @@ -647,12 +649,12 @@ def handle_exit_choice(action: str | None) -> None: self._selected_exit_action = action try: - self.query_one(f"#{WidgetID.STATUS}").update("Status: shutting down") + self.query_one(f"#{widgets.WidgetID.STATUS}").update("Status: shutting down") except (ValueError, AttributeError, RuntimeError) as e: logger.debug("Failed to update status widget during quit: %s", e) try: - log_widget = self.query_one(RichLog) + log_widget = self.query_one(SelectableLog) msg = "Graceful shutdown initiated (press Ctrl+C again to force exit)" log_widget.write(msg) except (ValueError, AttributeError, RuntimeError) as e: diff --git a/campers/tui/widgets/__init__.py b/campers/tui/widgets/__init__.py new file mode 100644 index 0000000..df0a066 --- /dev/null +++ b/campers/tui/widgets/__init__.py @@ -0,0 +1,6 @@ +"""TUI widgets module for campers.""" + +from campers.tui.widgets.selectable_log import SelectableLog +from campers.tui.widgets.selection import Selection + +__all__ = ["SelectableLog", "Selection"] diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py new file mode 100644 index 0000000..1223152 --- /dev/null +++ b/campers/tui/widgets/selectable_log.py @@ -0,0 +1,272 @@ +"""Selectable log widget for TUI application.""" + +from __future__ import annotations + +import logging +from typing import ClassVar + +from rich.style import Style +from rich.text import Text +from textual.geometry import Offset, Size +from textual.scroll_view import ScrollView +from textual.strip import Strip + +from campers.tui.widgets.selection import Selection + +logger = logging.getLogger(__name__) + + +class SelectableLog(ScrollView, can_focus=True): + """A scrollable log widget with text selection and clipboard support. + + This widget displays log content and allows users to select text with + the mouse and copy it to the clipboard. It sanitizes ANSI escape codes + while preserving color information. + + Parameters + ---------- + max_lines : int, optional + Maximum number of lines to store in buffer (default: 5000) + **kwargs + Additional keyword arguments passed to ScrollView + + Attributes + ---------- + lines : list[Text] + List of Rich Text objects representing log lines + max_lines : int + Maximum number of lines to store in buffer + selection : Selection | None + Current text selection, or None if no selection + """ + + BINDINGS: ClassVar = [ + ("ctrl+c", "copy", "Copy"), + ("ctrl+a", "select_all", "Select All"), + ] + + SELECTION_STYLE: ClassVar = Style(bgcolor="blue", color="white") + + def __init__(self, max_lines: int = 5000, **kwargs) -> None: + """Initialize SelectableLog widget. + + Parameters + ---------- + max_lines : int + Maximum number of lines to store (default: 5000) + **kwargs + Additional keyword arguments passed to ScrollView + """ + super().__init__(**kwargs) + self.lines: list[Text] = [] + self.max_lines = max_lines + self.selection: Selection | None = None + self._selecting = False + + def write(self, content: str | Text) -> None: + """Write content to the log widget. + + Converts ANSI-escaped strings to Rich Text and splits multiline + content into separate lines. Automatically trims buffer to max_lines + and scrolls to bottom. + + Parameters + ---------- + content : str | Text + Content to write (string with optional ANSI codes or Rich Text) + """ + text = Text.from_ansi(content) if isinstance(content, str) else content + + for line in text.split("\n"): + self.lines.append(line) + + if len(self.lines) > self.max_lines: + self.lines = self.lines[-self.max_lines:] + self.selection = None + + max_width = max((len(line.plain) for line in self.lines), default=0) + self.virtual_size = Size(max_width, len(self.lines)) + self.scroll_end(animate=False) + self.refresh() + + def render_line(self, y: int) -> Strip: + """Render a single line of the log. + + Accounts for scroll offset and applies selection styling if the line + contains selected content. + + Parameters + ---------- + y : int + Y coordinate relative to viewport + + Returns + ------- + Strip + Rendered line as a Strip object + """ + scroll_y = self.scroll_offset.y + line_index = y + scroll_y + + if line_index >= len(self.lines): + return Strip([]) + + line = self.lines[line_index].copy() + + if self.selection: + start, end = self.selection.normalized + if start[0] <= line_index <= end[0]: + start_col = start[1] if line_index == start[0] else 0 + end_col = end[1] if line_index == end[0] else len(line.plain) + line.stylize(self.SELECTION_STYLE, start_col, end_col) + + return Strip(line.render(self.app.console)) + + def _screen_to_content(self, offset: Offset) -> tuple[int, int]: + """Convert screen coordinates to content coordinates. + + Accounts for scroll offset when converting from screen position to + content position. + + Parameters + ---------- + offset : Offset + Screen position (relative to widget viewport) + + Returns + ------- + tuple[int, int] + Content position as (line, column) + """ + scroll_x, scroll_y = self.scroll_offset + line = max(0, offset.y + scroll_y) + col = max(0, offset.x + scroll_x) + return (line, col) + + def on_mouse_down(self, event) -> None: + """Handle mouse down event to start selection. + + Parameters + ---------- + event + Mouse event from Textual + """ + if event.button != 1: + return + self.capture_mouse() + self._selecting = True + pos = self._screen_to_content(event.offset) + self.selection = Selection(start=pos, end=pos) + self.refresh() + + def on_mouse_move(self, event) -> None: + """Handle mouse move event to update selection. + + Parameters + ---------- + event + Mouse event from Textual + """ + if not self._selecting: + return + pos = self._screen_to_content(event.offset) + if self.selection: + self.selection.end = pos + self.refresh() + + def on_mouse_up(self, event) -> None: + """Handle mouse up event to end selection. + + Parameters + ---------- + event + Mouse event from Textual + """ + self._selecting = False + self.release_mouse() + + def get_selected_text(self) -> str: + """Extract plain text from current selection. + + Handles multi-line selections by extracting the appropriate portion + of each line. + + Returns + ------- + str + Selected text, or empty string if no selection + """ + if not self.selection: + return "" + + start, end = self.selection.normalized + + if start[0] >= len(self.lines): + return "" + + selected_lines = [] + + for i in range(start[0], min(end[0] + 1, len(self.lines))): + line_text = self.lines[i].plain + if i == start[0] and i == end[0]: + selected_lines.append(line_text[start[1] : end[1]]) + elif i == start[0]: + selected_lines.append(line_text[start[1] :]) + elif i == end[0]: + selected_lines.append(line_text[: end[1]]) + else: + selected_lines.append(line_text) + + return "\n".join(selected_lines) + + def action_copy(self) -> None: + """Copy selected text to clipboard. + + Attempts to copy using pyperclip first, falls back to Textual's + OSC 52 clipboard method if pyperclip fails. + """ + text = self.get_selected_text() + if not text: + return + + try: + import pyperclip + + pyperclip.copy(text) + self.app.notify("Copied to clipboard") + except Exception: + try: + self.app.copy_to_clipboard(text) + self.app.notify("Copied to clipboard") + except Exception: + self.app.notify("Clipboard unavailable", severity="warning") + + def on_key(self, event) -> None: + """Handle key press events. + + Intercepts Ctrl+C when text is selected to prevent event bubbling. + Allows Ctrl+C to bubble when no text is selected (for quit handler). + + Parameters + ---------- + event + Key event from Textual + """ + if event.key == "ctrl+c" and not self.get_selected_text(): + return + if event.key == "ctrl+c": + self.action_copy() + event.stop() + + def action_select_all(self) -> None: + """Select all text in the log. + + Updates selection to span from first character to last character + of the entire log content. + """ + if not self.lines: + return + last_line = len(self.lines) - 1 + last_col = len(self.lines[last_line].plain) + self.selection = Selection(start=(0, 0), end=(last_line, last_col)) + self.refresh() diff --git a/campers/tui/widgets/selection.py b/campers/tui/widgets/selection.py new file mode 100644 index 0000000..7f7c089 --- /dev/null +++ b/campers/tui/widgets/selection.py @@ -0,0 +1,39 @@ +"""Selection dataclass for text range representation.""" + +from dataclasses import dataclass + + +@dataclass +class Selection: + """Represents a text selection range in a log widget. + + Parameters + ---------- + start : tuple[int, int] + Start position as (line, column) tuple + end : tuple[int, int] + End position as (line, column) tuple + + Attributes + ---------- + start : tuple[int, int] + Start position as (line, column) tuple + end : tuple[int, int] + End position as (line, column) tuple + """ + + start: tuple[int, int] + end: tuple[int, int] + + @property + def normalized(self) -> tuple[tuple[int, int], tuple[int, int]]: + """Return normalized selection with start before end. + + Returns + ------- + tuple[tuple[int, int], tuple[int, int]] + Tuple of (start, end) with start <= end + """ + if self.start <= self.end: + return self.start, self.end + return self.end, self.start diff --git a/pyproject.toml b/pyproject.toml index ce2df5b..001ff29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "omegaconf>=2.3.0", "paramiko>=3.0.0,<4.0.0", "python-dotenv>=1.2.1", + "pyperclip>=1.9.0", "pyyaml>=6.0.3", "requests>=2.32.5", "rich>=14.1.0", diff --git a/tests/integration/features/steps/tui_selectable_log_steps.py b/tests/integration/features/steps/tui_selectable_log_steps.py new file mode 100644 index 0000000..d915852 --- /dev/null +++ b/tests/integration/features/steps/tui_selectable_log_steps.py @@ -0,0 +1,635 @@ +"""Step definitions for TUI SelectableLog widget feature.""" + +from behave import given, then, when +from behave.runner import Context +from rich.text import Text + + +class MockSelectableLog: + """Mock SelectableLog widget for testing.""" + + def __init__(self, max_lines: int = 5000) -> None: + self.lines: list[Text] = [] + self.max_lines = max_lines + self.selection: tuple[tuple[int, int], tuple[int, int]] | None = None + self.clipboard_content: str | None = None + self.notification: str | None = None + self.scroll_position = 0 + self.has_focus = False + + def write(self, content: str | Text) -> None: + text = Text.from_ansi(content) if isinstance(content, str) else content + + for line in text.split("\n"): + self.lines.append(line) + + if len(self.lines) > self.max_lines: + self.lines = self.lines[-self.max_lines :] + + self.scroll_position = len(self.lines) - 1 + + def get_selected_text(self) -> str: + if not self.selection: + return "" + + start, end = self.selection + if start > end: + start, end = end, start + + selected_lines = [] + for i in range(start[0], min(end[0] + 1, len(self.lines))): + line_text = self.lines[i].plain + if i == start[0] and i == end[0]: + selected_lines.append(line_text[start[1] : end[1]]) + elif i == start[0]: + selected_lines.append(line_text[start[1] :]) + elif i == end[0]: + selected_lines.append(line_text[: end[1]]) + else: + selected_lines.append(line_text) + + return "\n".join(selected_lines) + + def copy_to_clipboard(self) -> None: + text = self.get_selected_text() + if text: + self.clipboard_content = text + self.notification = "Copied to clipboard" + + def select_all(self) -> None: + if not self.lines: + return + last_line = len(self.lines) - 1 + last_col = len(self.lines[last_line].plain) + self.selection = ((0, 0), (last_line, last_col)) + + def render_line(self, y: int) -> str: + if y >= len(self.lines): + return "" + return self.lines[y].plain + + +@given("a SelectableLog widget is created with max_lines {max_lines:d}") +def step_create_selectable_log(context: Context, max_lines: int) -> None: + """Create a SelectableLog widget with specified max_lines. + + Parameters + ---------- + context : Context + Behave context + max_lines : int + Maximum number of lines to store + """ + context.selectable_log = MockSelectableLog(max_lines=max_lines) + + +@given("a SelectableLog widget with content \"{content}\" on line {line_num:d}") +def step_selectable_log_with_content(context: Context, content: str, line_num: int) -> None: + """Create a SelectableLog widget with specific content. + + Parameters + ---------- + context : Context + Behave context + content : str + Content to write + line_num : int + Line number (for validation) + """ + if not hasattr(context, "selectable_log") or context.selectable_log is None: + context.selectable_log = MockSelectableLog() + + context.selectable_log.write(content) + context.selectable_log.has_focus = True + + +@given("a SelectableLog widget with content \"{content}\"") +def step_selectable_log_with_content_simple(context: Context, content: str) -> None: + """Create a SelectableLog widget with specific content (simple variant). + + Parameters + ---------- + context : Context + Behave context + content : str + Content to write + """ + if not hasattr(context, "selectable_log") or context.selectable_log is None: + context.selectable_log = MockSelectableLog() + + context.selectable_log.write(content) + context.selectable_log.has_focus = True + + +@given("the widget has focus") +def step_widget_has_focus(context: Context) -> None: + """Mark widget as having focus. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "selectable_log") and context.selectable_log: + context.selectable_log.has_focus = True + + +@given("a SelectableLog widget with selected text \"{text}\"") +def step_selectable_log_with_selection(context: Context, text: str) -> None: + """Create a SelectableLog with specific text selected. + + Parameters + ---------- + context : Context + Behave context + text : str + Text to select + """ + if not hasattr(context, "selectable_log") or context.selectable_log is None: + context.selectable_log = MockSelectableLog() + + context.selectable_log.write(text) + context.selectable_log.selection = ((0, 0), (0, len(text))) + context.selectable_log.has_focus = True + + +@given("a SelectableLog widget with no selection") +def step_selectable_log_no_selection(context: Context) -> None: + """Create a SelectableLog with no text selected. + + Parameters + ---------- + context : Context + Behave context + """ + if not hasattr(context, "selectable_log") or context.selectable_log is None: + context.selectable_log = MockSelectableLog() + + context.selectable_log.selection = None + context.selectable_log.has_focus = True + + +@given("a SelectableLog widget is mounted in TUI app") +def step_selectable_log_mounted_in_app(context: Context) -> None: + """Create a SelectableLog mounted in TUI application. + + Parameters + ---------- + context : Context + Behave context + """ + context.selectable_log = MockSelectableLog() + context.tui_log_widget = context.selectable_log + + +@given("widget is scrolled to top") +def step_widget_scrolled_to_top(context: Context) -> None: + """Set widget scroll position to top. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "selectable_log") and context.selectable_log: + context.selectable_log.scroll_position = 0 + + +@when("text content \"{content}\" is written") +def step_write_text_content(context: Context, content: str) -> None: + """Write text content to SelectableLog. + + Parameters + ---------- + context : Context + Behave context + content : str + Content to write + """ + context.selectable_log.write(content) + + +@when("ANSI colored text \"{content}\" is written") +def step_write_ansi_colored_text(context: Context, content: str) -> None: + """Write ANSI colored text to SelectableLog. + + Parameters + ---------- + context : Context + Behave context + content : str + ANSI colored content to write + """ + decoded_content = content.encode("utf-8").decode("unicode_escape") + context.selectable_log.write(decoded_content) + context.last_written_content = decoded_content + + +@when("ANSI control sequence \"{sequence}\" is written") +def step_write_ansi_control_sequence(context: Context, sequence: str) -> None: + """Write ANSI control sequence to SelectableLog. + + Parameters + ---------- + context : Context + Behave context + sequence : str + Control sequence to write + """ + decoded_sequence = sequence.encode("utf-8").decode("unicode_escape") + context.selectable_log.write(decoded_sequence) + context.last_written_content = decoded_sequence + + +@when("{num:d} lines of text are written") +def step_write_multiple_lines(context: Context, num: int) -> None: + """Write multiple lines of text. + + Parameters + ---------- + context : Context + Behave context + num : int + Number of lines to write + """ + for i in range(num): + context.selectable_log.write(f"Line {i + 1}") + + +@when("user clicks at position ({x:d}, {y:d})") +def step_user_clicks(context: Context, x: int, y: int) -> None: + """Record user click position. + + Parameters + ---------- + context : Context + Behave context + x : int + X coordinate + y : int + Y coordinate + """ + context.click_start = (x, y) + context.click_current = (x, y) + + +@when("user drags to position ({x:d}, {y:d})") +def step_user_drags(context: Context, x: int, y: int) -> None: + """Record user drag end position. + + Parameters + ---------- + context : Context + Behave context + x : int + X coordinate + y : int + Y coordinate + """ + if hasattr(context, "click_start") and context.click_start: + start = context.click_start + context.selectable_log.selection = (start, (x, y)) + + +@when("user presses Ctrl+A") +def step_user_presses_ctrl_a(context: Context) -> None: + """Simulate Ctrl+A key press. + + Parameters + ---------- + context : Context + Behave context + """ + context.selectable_log.select_all() + + +@when("user presses Ctrl+C") +def step_user_presses_ctrl_c(context: Context) -> None: + """Simulate Ctrl+C key press. + + Parameters + ---------- + context : Context + Behave context + """ + if context.selectable_log.get_selected_text(): + context.selectable_log.copy_to_clipboard() + else: + context.ctrl_c_no_selection = True + + +@when("new content is written") +def step_new_content_written(context: Context) -> None: + """Write new content to trigger auto-scroll. + + Parameters + ---------- + context : Context + Behave context + """ + context.selectable_log.write("New log line") + + +@when("log message \"{message}\" is emitted") +def step_log_message_emitted(context: Context, message: str) -> None: + """Emit a log message to be captured by TUI. + + Parameters + ---------- + context : Context + Behave context + message : str + Log message to emit + """ + context.emitted_log_message = message + if hasattr(context, "tui_log_widget") and context.tui_log_widget: + context.tui_log_widget.write(message) + + +@then("the widget displays \"{content}\"") +def step_widget_displays_content(context: Context, content: str) -> None: + """Verify widget displays expected content. + + Parameters + ---------- + context : Context + Behave context + content : str + Expected content + """ + all_text = "".join([line.plain for line in context.selectable_log.lines]) + assert content in all_text, f"Expected '{content}' in widget, got: {all_text}" + + +@then("the text is displayed in red color") +def step_text_displayed_in_red(context: Context) -> None: + """Verify text is displayed with red styling. + + Parameters + ---------- + context : Context + Behave context + """ + if not context.selectable_log.lines: + raise AssertionError("No lines in widget") + + found_red = False + for line in context.selectable_log.lines: + if not line.spans: + continue + for span in line.spans: + if not span.style: + continue + style_str = str(span.style).lower() + has_red = "red" in style_str or "color(1)" in style_str + if has_red: + found_red = True + break + if found_red: + break + + line_info = [(line.plain, str(line.spans)) for line in context.selectable_log.lines] + assert found_red, f"Expected red color in widget. Lines: {line_info}" + + +@then("no raw escape characters are visible") +def step_no_escape_characters_visible(context: Context) -> None: + """Verify no raw ANSI escape characters in output. + + Parameters + ---------- + context : Context + Behave context + """ + all_text = "".join([line.plain for line in context.selectable_log.lines]) + assert "\x1b" not in all_text, f"Found escape character in: {all_text}" + assert "\033" not in all_text, f"Found escape character in: {all_text}" + + +@then("no control sequences are displayed") +def step_no_control_sequences_displayed(context: Context) -> None: + """Verify no control sequences are visible. + + Parameters + ---------- + context : Context + Behave context + """ + all_text = "".join([line.plain for line in context.selectable_log.lines]) + assert "\x1b[2J" not in all_text, "Clear screen sequence found" + assert "\x1b[H" not in all_text, "Cursor home sequence found" + + +@then("the widget renders without corruption") +def step_widget_renders_without_corruption(context: Context) -> None: + """Verify widget renders without errors. + + Parameters + ---------- + context : Context + Behave context + """ + assert len(context.selectable_log.lines) > 0, "Widget has no lines" + + +@then("only the last {num:d} lines are stored") +def step_only_last_lines_stored(context: Context, num: int) -> None: + """Verify buffer contains only last N lines. + + Parameters + ---------- + context : Context + Behave context + num : int + Expected number of lines + """ + assert len(context.selectable_log.lines) == num, ( + f"Expected {num} lines, got {len(context.selectable_log.lines)}" + ) + + +@then("the first {num:d} lines are discarded") +def step_first_lines_discarded(context: Context, num: int) -> None: + """Verify first N lines were removed from buffer. + + Parameters + ---------- + context : Context + Behave context + num : int + Number of lines that should be removed + """ + pass + + +@then("text is selected from column {start:d} to column {end:d}") +def step_text_selected_range(context: Context, start: int, end: int) -> None: + """Verify text selection range. + + Parameters + ---------- + context : Context + Behave context + start : int + Start column + end : int + End column + """ + if not context.selectable_log.selection: + raise AssertionError("No selection in widget") + + sel_start, sel_end = context.selectable_log.selection + assert sel_start[1] == start, f"Expected selection start {start}, got {sel_start[1]}" + assert sel_end[1] == end, f"Expected selection end {end}, got {sel_end[1]}" + + +@then("selection is visually highlighted") +def step_selection_visually_highlighted(context: Context) -> None: + """Verify selection is highlighted. + + Parameters + ---------- + context : Context + Behave context + """ + assert context.selectable_log.selection is not None, "Selection not set" + + +@then("all content is selected") +def step_all_content_selected(context: Context) -> None: + """Verify all content is selected. + + Parameters + ---------- + context : Context + Behave context + """ + if not context.selectable_log.lines: + raise AssertionError("Widget has no content") + + assert context.selectable_log.selection is not None, "No selection" + + +@then("selection spans from start to end") +def step_selection_spans_full_range(context: Context) -> None: + """Verify selection spans entire content. + + Parameters + ---------- + context : Context + Behave context + """ + if not context.selectable_log.selection: + raise AssertionError("No selection") + + start, end = context.selectable_log.selection + assert start[0] == 0 and start[1] == 0, "Selection should start at (0, 0)" + + +@then("text is copied to clipboard") +def step_text_copied_to_clipboard(context: Context) -> None: + """Verify text is in clipboard. + + Parameters + ---------- + context : Context + Behave context + """ + assert context.selectable_log.clipboard_content is not None, "Clipboard is empty" + + +@then("notification \"{notification}\" is shown") +def step_notification_shown(context: Context, notification: str) -> None: + """Verify notification is displayed. + + Parameters + ---------- + context : Context + Behave context + notification : str + Expected notification text + """ + assert context.selectable_log.notification == notification, ( + f"Expected notification '{notification}', got '{context.selectable_log.notification}'" + ) + + +@then("widget scrolls to show latest content") +def step_widget_scrolls_to_latest(context: Context) -> None: + """Verify widget scrolled to show latest content. + + Parameters + ---------- + context : Context + Behave context + """ + pass + + +@then("scroll position is at bottom") +def step_scroll_position_at_bottom(context: Context) -> None: + """Verify scroll position is at bottom. + + Parameters + ---------- + context : Context + Behave context + """ + expected_position = len(context.selectable_log.lines) - 1 + assert context.selectable_log.scroll_position == expected_position, ( + f"Expected scroll at {expected_position}, got {context.selectable_log.scroll_position}" + ) + + +@then("key event bubbles to application") +def step_key_event_bubbles(context: Context) -> None: + """Verify key event bubbles up. + + Parameters + ---------- + context : Context + Behave context + """ + assert hasattr(context, "ctrl_c_no_selection") and context.ctrl_c_no_selection, ( + "Ctrl+C should not be consumed when no selection" + ) + + +@then("application quit handler receives event") +def step_application_receives_quit(context: Context) -> None: + """Verify application quit handler would receive event. + + Parameters + ---------- + context : Context + Behave context + """ + pass + + +@then("message appears in SelectableLog widget") +def step_message_appears_in_widget(context: Context) -> None: + """Verify log message appears in widget. + + Parameters + ---------- + context : Context + Behave context + """ + all_text = "".join([line.plain for line in context.selectable_log.lines]) + assert context.emitted_log_message in all_text, ( + f"Expected '{context.emitted_log_message}' in widget" + ) + + +@then("text can be selected and copied") +def step_text_can_be_selected_and_copied(context: Context) -> None: + """Verify text can be selected and copied. + + Parameters + ---------- + context : Context + Behave context + """ + all_text = "".join([line.plain for line in context.selectable_log.lines]) + assert len(all_text) > 0, "Widget has no text" diff --git a/tests/integration/features/tui_selectable_log.feature b/tests/integration/features/tui_selectable_log.feature new file mode 100644 index 0000000..299caeb --- /dev/null +++ b/tests/integration/features/tui_selectable_log.feature @@ -0,0 +1,75 @@ +Feature: TUI SelectableLog Widget + +@smoke +Scenario: SelectableLog widget renders log content + Given a SelectableLog widget is created with max_lines 5000 + When text content "Test log line" is written + Then the widget displays "Test log line" + +@smoke +Scenario: ANSI color codes are preserved in output + Given a SelectableLog widget is created with max_lines 5000 + When ANSI colored text "\x1b[31mError\x1b[0m" is written + Then the text is displayed in red color + And no raw escape characters are visible + +@smoke +Scenario: ANSI control sequences are stripped + Given a SelectableLog widget is created with max_lines 5000 + When ANSI control sequence "\x1b[2J\x1b[H" is written + Then no control sequences are displayed + And the widget renders without corruption + +@smoke +Scenario: Buffer is trimmed when max_lines exceeded + Given a SelectableLog widget is created with max_lines 10 + When 15 lines of text are written + Then only the last 10 lines are stored + And the first 5 lines are discarded + +@smoke +Scenario: User can select text with mouse drag + Given a SelectableLog widget with content "Line one" on line 0 + And the widget has focus + When user clicks at position (0, 0) + And user drags to position (0, 8) + Then text is selected from column 0 to column 8 + And selection is visually highlighted + +@smoke +Scenario: Ctrl+A selects all text + Given a SelectableLog widget with content "First line" + And the widget has focus + When user presses Ctrl+A + Then all content is selected + And selection spans from start to end + +@smoke +Scenario: Copy selected text with Ctrl+C + Given a SelectableLog widget with selected text "192.168.1.1" + When user presses Ctrl+C + Then text is copied to clipboard + And notification "Copied to clipboard" is shown + +@smoke +Scenario: Auto-scroll to bottom on new content + Given a SelectableLog widget is created with max_lines 5000 + And widget is scrolled to top + When new content is written + Then widget scrolls to show latest content + And scroll position is at bottom + +@smoke +Scenario: Ctrl+C bubbles when no text selected + Given a SelectableLog widget with no selection + And the widget has focus + When user presses Ctrl+C + Then key event bubbles to application + And application quit handler receives event + +@smoke +Scenario: TuiLogHandler integration + Given a SelectableLog widget is mounted in TUI app + When log message "Application started" is emitted + Then message appears in SelectableLog widget + And text can be selected and copied diff --git a/tests/unit/test_selectable_log.py b/tests/unit/test_selectable_log.py new file mode 100644 index 0000000..7ab949d --- /dev/null +++ b/tests/unit/test_selectable_log.py @@ -0,0 +1,236 @@ +"""Unit tests for SelectableLog widget and Selection dataclass.""" + +from rich.text import Text + +from campers.tui.widgets.selectable_log import SelectableLog +from campers.tui.widgets.selection import Selection + + +class TestSelection: + """Tests for Selection dataclass.""" + + def test_selection_creation(self) -> None: + """Test creating a Selection.""" + selection = Selection(start=(0, 0), end=(1, 5)) + assert selection.start == (0, 0) + assert selection.end == (1, 5) + + def test_selection_normalized_ascending(self) -> None: + """Test normalized property when start is before end.""" + selection = Selection(start=(0, 0), end=(1, 5)) + start, end = selection.normalized + assert start == (0, 0) + assert end == (1, 5) + + def test_selection_normalized_descending(self) -> None: + """Test normalized property when start is after end.""" + selection = Selection(start=(1, 5), end=(0, 0)) + start, end = selection.normalized + assert start == (0, 0) + assert end == (1, 5) + + def test_selection_normalized_same_line_ascending(self) -> None: + """Test normalized property on same line with ascending columns.""" + selection = Selection(start=(0, 5), end=(0, 10)) + start, end = selection.normalized + assert start == (0, 5) + assert end == (0, 10) + + def test_selection_normalized_same_line_descending(self) -> None: + """Test normalized property on same line with descending columns.""" + selection = Selection(start=(0, 10), end=(0, 5)) + start, end = selection.normalized + assert start == (0, 5) + assert end == (0, 10) + + +class TestSelectableLogBasic: + """Tests for SelectableLog widget basic functionality.""" + + def test_selectable_log_creation(self) -> None: + """Test creating a SelectableLog widget.""" + log = SelectableLog(max_lines=1000) + assert log.lines == [] + assert log.max_lines == 1000 + assert log.selection is None + + def test_selectable_log_default_max_lines(self) -> None: + """Test SelectableLog uses default max_lines.""" + log = SelectableLog() + assert log.max_lines == 5000 + + def test_write_string_content(self) -> None: + """Test writing string content to SelectableLog.""" + log = SelectableLog() + log.write("Test line") + assert len(log.lines) == 1 + assert log.lines[0].plain == "Test line" + + def test_write_rich_text_content(self) -> None: + """Test writing Rich Text content to SelectableLog.""" + log = SelectableLog() + text = Text("Styled text") + log.write(text) + assert len(log.lines) == 1 + assert log.lines[0].plain == "Styled text" + + def test_write_multiline_string(self) -> None: + """Test writing multiline string content.""" + log = SelectableLog() + log.write("Line 1\nLine 2\nLine 3") + assert len(log.lines) == 3 + assert log.lines[0].plain == "Line 1" + assert log.lines[1].plain == "Line 2" + assert log.lines[2].plain == "Line 3" + + +class TestSelectableLogAnsi: + """Tests for ANSI handling in SelectableLog.""" + + def test_ansi_color_preserved(self) -> None: + """Test ANSI color codes are converted to Rich styles.""" + log = SelectableLog() + log.write("\x1b[31mRed text\x1b[0m") + assert len(log.lines) == 1 + assert log.lines[0].plain == "Red text" + + def test_ansi_control_sequence_stripped(self) -> None: + """Test ANSI control sequences are stripped.""" + log = SelectableLog() + log.write("\x1b[2J\x1b[HClear screen") + all_text = "".join([line.plain for line in log.lines]) + assert "\x1b[2J" not in all_text + assert "\x1b[H" not in all_text + + def test_ansi_escape_char_not_visible(self) -> None: + """Test escape character is not in plain text output.""" + log = SelectableLog() + log.write("\x1b[31mColored\x1b[0m") + all_text = "".join([line.plain for line in log.lines]) + assert "\x1b" not in all_text + assert "\033" not in all_text + + +class TestSelectableLogBufferManagement: + """Tests for buffer management in SelectableLog.""" + + def test_buffer_trimming_exceeds_max_lines(self) -> None: + """Test buffer is trimmed when exceeding max_lines.""" + log = SelectableLog(max_lines=5) + for i in range(10): + log.write(f"Line {i}") + assert len(log.lines) == 5 + + def test_buffer_trimming_keeps_last_lines(self) -> None: + """Test trimmed buffer contains the last lines.""" + log = SelectableLog(max_lines=5) + for i in range(10): + log.write(f"Line {i}") + assert log.lines[0].plain == "Line 5" + assert log.lines[-1].plain == "Line 9" + + def test_buffer_no_trimming_below_max_lines(self) -> None: + """Test buffer is not trimmed when below max_lines.""" + log = SelectableLog(max_lines=10) + for i in range(5): + log.write(f"Line {i}") + assert len(log.lines) == 5 + + def test_buffer_exact_max_lines(self) -> None: + """Test buffer at exactly max_lines is not trimmed.""" + log = SelectableLog(max_lines=5) + for i in range(5): + log.write(f"Line {i}") + assert len(log.lines) == 5 + + def test_virtual_size_updated_after_write(self) -> None: + """Test virtual_size is updated after writing.""" + log = SelectableLog() + log.write("Short") + width1, height1 = log.virtual_size + assert height1 == 1 + + log.write("Much longer line here") + width2, height2 = log.virtual_size + assert height2 == 2 + assert width2 >= 21 + + def test_virtual_size_updated_after_trimming(self) -> None: + """Test virtual_size is updated after buffer trimming.""" + log = SelectableLog(max_lines=3) + for i in range(5): + log.write(f"Line {i}") + width, height = log.virtual_size + assert height == 3 + + +class TestSelectableLogSelection: + """Tests for text selection in SelectableLog.""" + + def test_get_selected_text_single_line(self) -> None: + """Test getting selected text on a single line.""" + log = SelectableLog() + log.write("Hello World") + log.selection = Selection(start=(0, 0), end=(0, 5)) + assert log.get_selected_text() == "Hello" + + def test_get_selected_text_multiline(self) -> None: + """Test getting selected text across multiple lines.""" + log = SelectableLog() + log.write("Line 1\nLine 2\nLine 3") + log.selection = Selection(start=(0, 3), end=(2, 4)) + expected = "e 1\nLine 2\nLine" + assert log.get_selected_text() == expected + + def test_get_selected_text_no_selection(self) -> None: + """Test getting selected text when no selection.""" + log = SelectableLog() + log.write("Test content") + assert log.get_selected_text() == "" + + def test_get_selected_text_reversed_selection(self) -> None: + """Test getting selected text with reversed selection.""" + log = SelectableLog() + log.write("Hello World") + log.selection = Selection(start=(0, 5), end=(0, 0)) + assert log.get_selected_text() == "Hello" + + def test_get_selected_text_reversed_multiline(self) -> None: + """Test getting selected text with reversed multiline selection.""" + log = SelectableLog() + log.write("Line 1\nLine 2\nLine 3") + log.selection = Selection(start=(2, 4), end=(0, 3)) + expected = "e 1\nLine 2\nLine" + assert log.get_selected_text() == expected + + def test_get_selected_text_full_lines(self) -> None: + """Test getting selected text for multiple full lines.""" + log = SelectableLog() + log.write("Line 1\nLine 2\nLine 3") + log.selection = Selection(start=(0, 0), end=(2, 6)) + expected = "Line 1\nLine 2\nLine 3" + assert log.get_selected_text() == expected + + def test_get_selected_text_partial_first_last(self) -> None: + """Test partial selection on first and last lines.""" + log = SelectableLog() + log.write("First\nMiddle\nLast") + log.selection = Selection(start=(0, 3), end=(2, 2)) + expected = "st\nMiddle\nLa" + assert log.get_selected_text() == expected + + +class TestSelectableLogEmptyOperations: + """Tests for operations on empty SelectableLog.""" + + def test_get_selected_text_empty_log(self) -> None: + """Test getting selected text from empty log.""" + log = SelectableLog() + assert log.get_selected_text() == "" + + def test_selection_normalized_on_empty_log(self) -> None: + """Test selection normalization doesn't crash on empty log.""" + selection = Selection(start=(0, 0), end=(1, 5)) + start, end = selection.normalized + assert start == (0, 0) + assert end == (1, 5) diff --git a/uv.lock b/uv.lock index cf01b29..de791d4 100644 --- a/uv.lock +++ b/uv.lock @@ -191,7 +191,7 @@ wheels = [ [[package]] name = "campers" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "ansible" }, @@ -199,6 +199,7 @@ dependencies = [ { name = "fire" }, { name = "omegaconf" }, { name = "paramiko" }, + { name = "pyperclip" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, @@ -228,6 +229,7 @@ requires-dist = [ { name = "fire", specifier = ">=0.7.1" }, { name = "omegaconf", specifier = ">=2.3.0" }, { name = "paramiko", specifier = ">=3.0.0,<4.0.0" }, + { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.32.5" }, @@ -1037,6 +1039,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/462326910c6172fa2c6ed07922b22ffc8e77432b3affffd9e18f444dbfbb/pynacl-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2", size = 183846, upload-time = "2025-09-10T23:39:10.552Z" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" From a02b5a8309bd9308c638f60e52da08f00102797d Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Fri, 19 Dec 2025 13:35:11 +0100 Subject: [PATCH 02/14] Add context menu widget and integration tests for TUI - Implement ContextMenu widget for right-click actions in TUI - Add WidgetID constants for widget identifiers - Integrate ContextMenu into widgets module - Add integration tests and feature scenarios for context menu behavior - Fix minor formatting in test and widget files --- campers/tui/app.py | 22 + campers/tui/widgets/__init__.py | 19 +- campers/tui/widgets/context_menu.py | 266 +++++++++ campers/tui/widgets/selectable_log.py | 23 +- .../integration/features/steps/exec_steps.py | 36 +- .../features/steps/tui_context_menu_steps.py | 553 ++++++++++++++++++ .../steps/tui_selectable_log_steps.py | 18 +- .../features/tui_context_menu.feature | 82 +++ tests/unit/fakes/fake_ssh_manager.py | 6 +- tests/unit/test_ssh.py | 16 +- 10 files changed, 994 insertions(+), 47 deletions(-) create mode 100644 campers/tui/widgets/context_menu.py create mode 100644 tests/integration/features/steps/tui_context_menu_steps.py create mode 100644 tests/integration/features/tui_context_menu.feature diff --git a/campers/tui/app.py b/campers/tui/app.py index 9fe7243..8b4733c 100644 --- a/campers/tui/app.py +++ b/campers/tui/app.py @@ -31,6 +31,7 @@ from campers.tui.instance_overview_widget import InstanceOverviewWidget from campers.tui.styling import TUI_CSS from campers.tui.terminal import detect_terminal_background +from campers.tui.widgets.context_menu import ContextMenu from campers.tui.widgets.selectable_log import SelectableLog if TYPE_CHECKING: @@ -117,6 +118,8 @@ def compose(self) -> ComposeResult: Status panel container with static widgets Container Log panel container with log widget + ContextMenu + Context menu for SelectableLog actions """ with Container(id="status-panel"): yield InstanceOverviewWidget(self.campers) @@ -132,6 +135,7 @@ def compose(self) -> ComposeResult: yield Static("", id=widgets.WidgetID.PUBLIC_PORTS, classes="hidden") with Container(id="log-panel"): yield SelectableLog() + yield ContextMenu() def on_mount(self) -> None: """Handle mount event - setup logging, start worker, and timer.""" @@ -177,6 +181,24 @@ async def on_tui_log_message(self, message: TuiLogMessage) -> None: self.log_widget.write(message.text) + async def on_context_menu_item_selected(self, message: ContextMenu.ItemSelected) -> None: + """Handle context menu item selection. + + Parameters + ---------- + message : ContextMenu.ItemSelected + Message containing the selected action + """ + menu = self.query_one(ContextMenu) + target = menu._target_widget + + if message.action == "copy" and hasattr(target, "action_copy"): + target.action_copy() + elif message.action == "search": + pass + elif message.action == "clear" and hasattr(target, "clear"): + target.clear() + def check_for_updates(self) -> None: """Check queue for updates and update widgets accordingly. diff --git a/campers/tui/widgets/__init__.py b/campers/tui/widgets/__init__.py index df0a066..349876d 100644 --- a/campers/tui/widgets/__init__.py +++ b/campers/tui/widgets/__init__.py @@ -1,6 +1,23 @@ """TUI widgets module for campers.""" +from campers.tui.widgets.context_menu import ContextMenu from campers.tui.widgets.selectable_log import SelectableLog from campers.tui.widgets.selection import Selection -__all__ = ["SelectableLog", "Selection"] + +class WidgetID: + """Constants for TUI widget identifiers.""" + + UPTIME = "uptime-widget" + STATUS = "status-widget" + SSH = "ssh-widget" + INSTANCE_TYPE = "instance-type-widget" + REGION = "region-widget" + CAMP_NAME = "camp-name-widget" + COMMAND = "command-widget" + MUTAGEN = "mutagen-widget" + PORTFORWARD = "portforward-widget" + PUBLIC_PORTS = "public-ports-widget" + + +__all__ = ["ContextMenu", "SelectableLog", "Selection", "WidgetID"] diff --git a/campers/tui/widgets/context_menu.py b/campers/tui/widgets/context_menu.py new file mode 100644 index 0000000..54b01a7 --- /dev/null +++ b/campers/tui/widgets/context_menu.py @@ -0,0 +1,266 @@ +"""Context menu widget for right-click actions on selectable widgets.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from textual.containers import Container +from textual.message import Message +from textual.widgets import Static + +if TYPE_CHECKING: + from textual.app import ComposeResult + +logger = logging.getLogger(__name__) + + +class ContextMenu(Container): + """A context menu widget that appears on right-click at mouse coordinates. + + This is a reusable component that can be used with any widget. It displays + a list of menu items that the user can activate via clicking or keyboard + navigation. + + Parameters + ---------- + items : list[str] | None + List of menu item labels (default: ["Copy", "Search", "Clear"]) + **kwargs + Additional keyword arguments passed to Container + + Attributes + ---------- + items : list[str] + List of menu item labels + highlighted_index : int + Index of currently highlighted menu item + target_widget + Reference to the widget that triggered the context menu + disabled_items : list[str] + List of item labels that are currently disabled + """ + + DEFAULT_CSS = """ + ContextMenu { + position: absolute; + width: 20; + height: auto; + background: #383838; + border: solid #606060; + display: none; + layer: overlay; + } + + ContextMenu.visible { + display: block; + } + + ContextMenu .menu-item { + padding: 0 2; + height: 1; + color: #ffffff; + } + + ContextMenu .menu-item:hover { + background: #505050; + } + + ContextMenu .menu-item.disabled { + color: #808080; + } + + ContextMenu .menu-item.highlighted { + background: #505050; + } + """ + + class ItemSelected(Message): + """Posted when a menu item is selected. + + Parameters + ---------- + action : str + Name of the selected action (lowercase item name) + """ + + def __init__(self, action: str) -> None: + self.action = action + super().__init__() + + def __init__(self, items: list[str] | None = None, **kwargs) -> None: + """Initialize ContextMenu widget. + + Parameters + ---------- + items : list[str] | None + List of menu item labels (default: ["Copy", "Search", "Clear"]) + **kwargs + Additional keyword arguments passed to Container + """ + super().__init__(**kwargs) + self._items = items if items is not None else ["Copy", "Search", "Clear"] + self._highlighted_index = 0 + self._target_widget = None + self._disabled_items: list[str] = [] + self._would_overflow = False + + def compose(self) -> ComposeResult: + """Compose menu items. + + Yields + ------ + Static + A Static widget for each menu item + """ + for item in self._items: + yield Static(item, classes="menu-item") + + def show_at( + self, + x: int, + y: int, + target_widget, + disabled_items: list[str] | None = None, + ) -> None: + """Show context menu at specified position. + + Adjusts position if menu would overflow viewport bounds. + + Parameters + ---------- + x : int + X coordinate (screen position) + y : int + Y coordinate (screen position) + target_widget + Reference to widget that triggered this menu + disabled_items : list[str] | None + List of item names to disable (default: []) + """ + self._target_widget = target_widget + self._highlighted_index = 0 + self._disabled_items = disabled_items or [] + + viewport_width = self.app.size.width + viewport_height = self.app.size.height + menu_width = 20 + menu_height = len(self._items) + + adjusted_x = x + adjusted_y = y + self._would_overflow = False + + if x + menu_width > viewport_width: + adjusted_x = max(0, x - menu_width) + self._would_overflow = True + + if y + menu_height > viewport_height: + adjusted_y = max(0, y - menu_height) + self._would_overflow = True + + self.styles.offset = (adjusted_x, adjusted_y) + self.add_class("visible") + self._update_disabled_state() + self.focus() + self._update_highlight() + + def hide(self) -> None: + """Hide the context menu.""" + self.remove_class("visible") + self._target_widget = None + + def _update_disabled_state(self) -> None: + """Update CSS classes for disabled items.""" + items = self.query(".menu-item") + for i, item in enumerate(items): + if self._items[i] in self._disabled_items: + item.add_class("disabled") + else: + item.remove_class("disabled") + + def _update_highlight(self) -> None: + """Update CSS classes for highlighted item.""" + items = self.query(".menu-item") + for i, item in enumerate(items): + if i == self._highlighted_index: + item.add_class("highlighted") + else: + item.remove_class("highlighted") + + def on_key(self, event) -> None: + """Handle key press events. + + Parameters + ---------- + event + Key event from Textual + """ + if event.key == "escape": + self.hide() + event.stop() + elif event.key == "up": + self._highlighted_index = (self._highlighted_index - 1) % len(self._items) + self._update_highlight() + event.stop() + elif event.key == "down": + self._highlighted_index = (self._highlighted_index + 1) % len(self._items) + self._update_highlight() + event.stop() + elif event.key == "enter": + self._activate_item(self._highlighted_index) + event.stop() + + def on_click(self, event) -> None: + """Handle mouse click on menu items. + + Parameters + ---------- + event + Click event from Textual + """ + for i, item in enumerate(self.query(".menu-item")): + if item.region.contains(event.screen_x, event.screen_y): + self._activate_item(i) + return + + def _activate_item(self, index: int) -> None: + """Activate a menu item by index. + + Parameters + ---------- + index : int + Index of item to activate + """ + if index < 0 or index >= len(self._items): + return + + item_name = self._items[index] + if item_name in self._disabled_items: + return + + action = item_name.lower() + self.post_message(self.ItemSelected(action)) + self.hide() + + @property + def would_overflow(self) -> bool: + """Check if menu position required adjustment for viewport bounds. + + Returns + ------- + bool + True if menu would overflow and was adjusted, False otherwise + """ + return self._would_overflow + + @property + def items(self) -> list[str]: + """Get menu items. + + Returns + ------- + list[str] + List of menu item labels + """ + return self._items diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index 1223152..9ea557e 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -81,7 +81,7 @@ def write(self, content: str | Text) -> None: self.lines.append(line) if len(self.lines) > self.max_lines: - self.lines = self.lines[-self.max_lines:] + self.lines = self.lines[-self.max_lines :] self.selection = None max_width = max((len(line.plain) for line in self.lines), default=0) @@ -151,6 +151,17 @@ def on_mouse_down(self, event) -> None: event Mouse event from Textual """ + if event.button == 3: + from campers.tui.widgets.context_menu import ContextMenu + + menu = self.app.query_one(ContextMenu) + disabled = [] + if not self.get_selected_text(): + disabled.append("Copy") + menu.show_at(event.screen_x, event.screen_y, self, disabled_items=disabled) + event.stop() + return + if event.button != 1: return self.capture_mouse() @@ -270,3 +281,13 @@ def action_select_all(self) -> None: last_col = len(self.lines[last_line].plain) self.selection = Selection(start=(0, 0), end=(last_line, last_col)) self.refresh() + + def clear(self) -> None: + """Clear all lines from the log widget. + + Removes all content, resets selection, updates virtual size, and refreshes display. + """ + self.lines = [] + self.selection = None + self.virtual_size = Size(0, 0) + self.refresh() diff --git a/tests/integration/features/steps/exec_steps.py b/tests/integration/features/steps/exec_steps.py index 5af9a2d..09950b9 100644 --- a/tests/integration/features/steps/exec_steps.py +++ b/tests/integration/features/steps/exec_steps.py @@ -179,7 +179,7 @@ def step_multiple_instances_for_camp(context: Context, camp_name_quoted: str) -> "camp_config": camp_name, "state": "running", "region": "us-east-1", - "public_ip": f"54.23.45.{67+i}", + "public_ip": f"54.23.45.{67 + i}", "key_file": "/home/user/.campers/keys/id_rsa", "owner": get_test_user_identity(), "unique_id": str(uuid.uuid4())[:12], @@ -250,8 +250,8 @@ def step_run_exec_command_with_region( logger.error(f"Exec command failed: {e}") -@when('I run campers exec {camp_or_id_quoted} with command {command_quoted}') -@when('I run campers exec {camp_or_id_quoted} {flags} with command {command_quoted}') +@when("I run campers exec {camp_or_id_quoted} with command {command_quoted}") +@when("I run campers exec {camp_or_id_quoted} {flags} with command {command_quoted}") def step_run_exec_command( context: Context, camp_or_id_quoted: str, command_quoted: str, flags: str = None ) -> None: @@ -288,9 +288,9 @@ def step_run_exec_command( @then("the command should execute successfully") def step_command_executes_successfully(context: Context) -> None: """Verify the exec command executed successfully.""" - assert ( - context.exit_code == 0 - ), f"Expected exit code 0, got {context.exit_code}. stderr: {context.stderr}" + assert context.exit_code == 0, ( + f"Expected exit code 0, got {context.exit_code}. stderr: {context.stderr}" + ) @then("the command should fail") @@ -302,9 +302,9 @@ def step_command_fails(context: Context) -> None: @then("the exit code should be {exit_code:d}") def step_exit_code_is_exact(context: Context, exit_code: int) -> None: """Verify the exit code matches expected value.""" - assert ( - context.exit_code == exit_code - ), f"Expected exit code {exit_code}, got {context.exit_code}" + assert context.exit_code == exit_code, ( + f"Expected exit code {exit_code}, got {context.exit_code}" + ) @then("exec output contains {expected_text_quoted}") @@ -313,9 +313,7 @@ def step_output_contains(context: Context, expected_text_quoted: str) -> None: expected_text = expected_text_quoted.strip('"') output = context.stdout + context.stderr - assert ( - expected_text in output - ), f"Expected '{expected_text}' in output, got: {output}" + assert expected_text in output, f"Expected '{expected_text}' in output, got: {output}" @then("exec error message includes {expected_msg_quoted}") @@ -324,9 +322,7 @@ def step_error_includes(context: Context, expected_msg_quoted: str) -> None: expected_msg = expected_msg_quoted.strip('"') error_output = context.stderr + getattr(context, "command_error", "") - assert ( - expected_msg in error_output - ), f"Expected '{expected_msg}' in error, got: {error_output}" + assert expected_msg in error_output, f"Expected '{expected_msg}' in error, got: {error_output}" @then("error message indicates instance is not running") @@ -334,9 +330,9 @@ def step_error_not_running(context: Context) -> None: """Verify error message indicates instance is not in running state.""" error_output = context.stderr + getattr(context, "command_error", "") - assert ( - "not running" in error_output or "stopped" in error_output - ), f"Expected 'not running' or 'stopped' in error, got: {error_output}" + assert "not running" in error_output or "stopped" in error_output, ( + f"Expected 'not running' or 'stopped' in error, got: {error_output}" + ) @then("the command should execute successfully on the {region} instance") @@ -377,6 +373,4 @@ def step_warning_was_logged(context: Context, expected_msg_quoted: str) -> None: output = context.stdout + context.stderr - assert ( - expected_msg in output - ), f"Expected warning '{expected_msg}' in output, got: {output}" + assert expected_msg in output, f"Expected warning '{expected_msg}' in output, got: {output}" diff --git a/tests/integration/features/steps/tui_context_menu_steps.py b/tests/integration/features/steps/tui_context_menu_steps.py new file mode 100644 index 0000000..68d8b5c --- /dev/null +++ b/tests/integration/features/steps/tui_context_menu_steps.py @@ -0,0 +1,553 @@ +"""Step definitions for TUI context menu feature.""" + +from behave import given, then, when +from behave.runner import Context +from rich.text import Text + + +class MockSelectableLog: + """Mock SelectableLog widget for context menu testing.""" + + def __init__(self, max_lines: int = 5000) -> None: + self.lines: list[Text] = [] + self.max_lines = max_lines + self.selection: tuple[tuple[int, int], tuple[int, int]] | None = None + self.clipboard_content: str | None = None + self.notification: str | None = None + + def write(self, content: str | Text) -> None: + """Write content to widget. + + Parameters + ---------- + content : str | Text + Content to write + """ + text = Text.from_ansi(content) if isinstance(content, str) else content + + for line in text.split("\n"): + self.lines.append(line) + + if len(self.lines) > self.max_lines: + self.lines = self.lines[-self.max_lines :] + + def get_selected_text(self) -> str: + """Get currently selected text. + + Returns + ------- + str + Selected text or empty string + """ + if not self.selection: + return "" + + start, end = self.selection + if start > end: + start, end = end, start + + selected_lines = [] + for i in range(start[0], min(end[0] + 1, len(self.lines))): + line_text = self.lines[i].plain + if i == start[0] and i == end[0]: + selected_lines.append(line_text[start[1] : end[1]]) + elif i == start[0]: + selected_lines.append(line_text[start[1] :]) + elif i == end[0]: + selected_lines.append(line_text[: end[1]]) + else: + selected_lines.append(line_text) + + return "\n".join(selected_lines) + + def copy_to_clipboard(self) -> None: + """Copy selected text to clipboard.""" + text = self.get_selected_text() + if text: + self.clipboard_content = text + self.notification = "Copied to clipboard" + + def clear(self) -> None: + """Clear all lines from widget.""" + self.lines = [] + self.selection = None + + +class MockContextMenu: + """Mock ContextMenu widget for testing.""" + + def __init__(self) -> None: + self.items = ["Copy", "Search", "Clear"] + self.visible = False + self.position = (0, 0) + self.highlighted_index = 0 + self.disabled_items = [] + self.last_activated_action = None + self.would_overflow = False + + def show_at( + self, + x: int, + y: int, + target_widget, + disabled_items: list[str] | None = None, + ) -> None: + """Show context menu at specified position. + + Parameters + ---------- + x : int + X coordinate + y : int + Y coordinate + target_widget + Target widget reference + disabled_items : list[str] | None + List of disabled item names + """ + self.visible = True + self.target_widget = target_widget + self.disabled_items = disabled_items or [] + self.highlighted_index = 0 + + viewport_width = 200 + viewport_height = 150 + menu_width = 20 + menu_height = len(self.items) + + adjusted_x = x + adjusted_y = y + self.would_overflow = False + + if x + menu_width > viewport_width: + adjusted_x = max(0, x - menu_width) + self.would_overflow = True + + if y + menu_height > viewport_height: + adjusted_y = max(0, y - menu_height) + self.would_overflow = True + + self.position = (adjusted_x, adjusted_y) + + def hide(self) -> None: + """Hide the context menu.""" + self.visible = False + self.target_widget = None + + def activate_item(self, index: int) -> None: + """Activate a menu item by index. + + Parameters + ---------- + index : int + Item index + """ + if index < 0 or index >= len(self.items): + return + + item_name = self.items[index] + if item_name in self.disabled_items: + return + + self.last_activated_action = item_name.lower() + self.hide() + + def highlight_item(self, index: int) -> None: + """Highlight a menu item. + + Parameters + ---------- + index : int + Item index + """ + if 0 <= index < len(self.items): + self.highlighted_index = index + + +@given("context menu is open") +def step_context_menu_open(context: Context) -> None: + """Open context menu with SelectableLog reference. + + Parameters + ---------- + context : Context + Behave context + """ + if not hasattr(context, "context_menu"): + context.context_menu = MockContextMenu() + + disabled = [] + if hasattr(context, "selectable_log"): + selected_text = context.selectable_log.get_selected_text() + if not selected_text: + disabled.append("Copy") + + context.context_menu.show_at(20, 5, context.selectable_log, disabled_items=disabled) + + +@given("context menu is open with {item} highlighted") +def step_context_menu_open_with_item_highlighted(context: Context, item: str) -> None: + """Open context menu with specific item highlighted. + + Parameters + ---------- + context : Context + Behave context + item : str + Item name to highlight + """ + if not hasattr(context, "context_menu"): + context.context_menu = MockContextMenu() + + disabled = [] + if hasattr(context, "selectable_log"): + selected_text = context.selectable_log.get_selected_text() + if not selected_text: + disabled.append("Copy") + + context.context_menu.show_at(20, 5, context.selectable_log, disabled_items=disabled) + + if item in context.context_menu.items: + index = context.context_menu.items.index(item) + context.context_menu.highlight_item(index) + + +@given("a SelectableLog widget with {num:d} lines of content") +def step_widget_with_n_lines(context: Context, num: int) -> None: + """Create widget with N lines of content. + + Parameters + ---------- + context : Context + Behave context + num : int + Number of lines + """ + if not hasattr(context, "selectable_log"): + context.selectable_log = MockSelectableLog() + + for i in range(num): + context.selectable_log.write(f"Line {i + 1}") + + +@when("user right-clicks on SelectableLog") +def step_user_right_clicks_widget(context: Context) -> None: + """Simulate right-click on SelectableLog. + + Parameters + ---------- + context : Context + Behave context + """ + if not hasattr(context, "context_menu"): + context.context_menu = MockContextMenu() + + disabled = [] + if hasattr(context, "selectable_log"): + selected_text = context.selectable_log.get_selected_text() + if not selected_text: + disabled.append("Copy") + + context.context_menu.show_at(20, 5, context.selectable_log, disabled_items=disabled) + context.right_click_triggered = True + + +@when("user right-clicks near viewport edge") +def step_user_right_clicks_near_edge(context: Context) -> None: + """Simulate right-click near viewport edge. + + Parameters + ---------- + context : Context + Behave context + """ + if not hasattr(context, "context_menu"): + context.context_menu = MockContextMenu() + + disabled = [] + if hasattr(context, "selectable_log"): + selected_text = context.selectable_log.get_selected_text() + if not selected_text: + disabled.append("Copy") + + context.context_menu.show_at(190, 148, context.selectable_log, disabled_items=disabled) + context.right_click_triggered = True + + +@when("user activates {item} from menu") +def step_user_activates_menu_item(context: Context, item: str) -> None: + """Activate a menu item. + + Parameters + ---------- + context : Context + Behave context + item : str + Item name + """ + if item not in context.context_menu.items: + raise AssertionError(f"Menu item '{item}' not found") + + index = context.context_menu.items.index(item) + + if item == "Copy" and hasattr(context, "selectable_log"): + context.selectable_log.copy_to_clipboard() + + if item == "Clear" and hasattr(context, "selectable_log"): + context.selectable_log.clear() + + if item == "Search": + context.context_menu_search_posted = True + + context.context_menu.activate_item(index) + + +@when("user clicks outside the menu area") +def step_user_clicks_outside_menu(context: Context) -> None: + """Simulate click outside menu. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "context_menu"): + context.context_menu.hide() + context.outside_click = True + + +@when("user presses Escape") +def step_user_presses_escape(context: Context) -> None: + """Simulate Escape key press. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "context_menu"): + context.context_menu.hide() + context.escape_pressed = True + + +@when("user presses Down arrow") +def step_user_presses_down_arrow(context: Context) -> None: + """Simulate Down arrow key press. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "context_menu"): + next_index = (context.context_menu.highlighted_index + 1) % len(context.context_menu.items) + context.context_menu.highlight_item(next_index) + + +@when("user presses Enter") +def step_user_presses_enter(context: Context) -> None: + """Simulate Enter key press. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "context_menu"): + item_index = context.context_menu.highlighted_index + item_name = context.context_menu.items[item_index] + + if item_name not in context.context_menu.disabled_items: + if item_name == "Clear" and hasattr(context, "selectable_log"): + context.selectable_log.clear() + elif item_name == "Copy" and hasattr(context, "selectable_log"): + context.selectable_log.copy_to_clipboard() + elif item_name == "Search": + context.context_menu_search_posted = True + + context.context_menu.activate_item(item_index) + + +@then("context menu is visible") +def step_context_menu_visible(context: Context) -> None: + """Verify context menu is visible. + + Parameters + ---------- + context : Context + Behave context + """ + assert hasattr(context, "context_menu"), "Context menu not created" + assert context.context_menu.visible, "Context menu is not visible" + + +@then("menu has Copy, Search, Clear items") +def step_menu_has_items(context: Context) -> None: + """Verify menu has standard items. + + Parameters + ---------- + context : Context + Behave context + """ + expected_items = ["Copy", "Search", "Clear"] + actual_items = context.context_menu.items + + assert actual_items == expected_items, f"Expected items {expected_items}, got {actual_items}" + + +@then("Copy menu item is enabled") +def step_copy_menu_item_enabled(context: Context) -> None: + """Verify Copy item is enabled. + + Parameters + ---------- + context : Context + Behave context + """ + assert "Copy" not in context.context_menu.disabled_items, "Copy should be enabled" + + +@then("context menu is adjusted within viewport") +def step_context_menu_adjusted(context: Context) -> None: + """Verify menu position was adjusted for viewport. + + Parameters + ---------- + context : Context + Behave context + """ + assert context.context_menu.would_overflow, "Menu was not adjusted" + + +@then("context menu is positioned within screen bounds") +def step_menu_positioned_within_bounds(context: Context) -> None: + """Verify menu is positioned within screen bounds. + + Parameters + ---------- + context : Context + Behave context + """ + x, y = context.context_menu.position + menu_width = 20 + menu_height = len(context.context_menu.items) + + assert x + menu_width <= 200, "Menu extends beyond right edge" + assert y + menu_height <= 150, "Menu extends beyond bottom edge" + + +@then("Copy menu item is disabled") +def step_copy_menu_item_disabled(context: Context) -> None: + """Verify Copy item is disabled. + + Parameters + ---------- + context : Context + Behave context + """ + assert "Copy" in context.context_menu.disabled_items, "Copy should be disabled" + + +@then("selecting Copy does nothing") +def step_selecting_copy_does_nothing(context: Context) -> None: + """Verify Copy activation has no effect when disabled. + + Parameters + ---------- + context : Context + Behave context + """ + copy_index = context.context_menu.items.index("Copy") + context.context_menu.activate_item(copy_index) + assert context.context_menu.last_activated_action != "copy", ( + "Copy should not activate when disabled" + ) + + +@then("widget displays no lines") +def step_widget_displays_no_lines(context: Context) -> None: + """Verify widget has no content. + + Parameters + ---------- + context : Context + Behave context + """ + assert len(context.selectable_log.lines) == 0, ( + f"Expected 0 lines, got {len(context.selectable_log.lines)}" + ) + + +@then("all content is cleared") +def step_all_content_cleared(context: Context) -> None: + """Verify all content is cleared. + + Parameters + ---------- + context : Context + Behave context + """ + assert len(context.selectable_log.lines) == 0, "Widget should have no content" + + +@then("ContextMenuSearch message is posted") +def step_context_menu_search_posted(context: Context) -> None: + """Verify ContextMenuSearch message was posted. + + Parameters + ---------- + context : Context + Behave context + """ + assert hasattr(context, "context_menu_search_posted"), ( + "ContextMenuSearch message was not posted" + ) + + +@then("context menu closes") +def step_context_menu_closes(context: Context) -> None: + """Verify context menu closes. + + Parameters + ---------- + context : Context + Behave context + """ + assert not context.context_menu.visible, "Context menu should be hidden" + + +@then("no menu action is performed") +def step_no_menu_action_performed(context: Context) -> None: + """Verify no menu action was performed. + + Parameters + ---------- + context : Context + Behave context + """ + assert not hasattr(context, "context_menu_search_posted") or ( + not context.context_menu_search_posted + ), "Action was performed when it should not" + + +@then("{item} item is highlighted") +def step_item_highlighted(context: Context, item: str) -> None: + """Verify item is highlighted. + + Parameters + ---------- + context : Context + Behave context + item : str + Item name + """ + if item not in context.context_menu.items: + raise AssertionError(f"Menu item '{item}' not found") + + expected_index = context.context_menu.items.index(item) + actual_index = context.context_menu.highlighted_index + + assert actual_index == expected_index, ( + f"Expected {item} highlighted at index {expected_index}, got index {actual_index}" + ) diff --git a/tests/integration/features/steps/tui_selectable_log_steps.py b/tests/integration/features/steps/tui_selectable_log_steps.py index d915852..c96f9cb 100644 --- a/tests/integration/features/steps/tui_selectable_log_steps.py +++ b/tests/integration/features/steps/tui_selectable_log_steps.py @@ -83,7 +83,7 @@ def step_create_selectable_log(context: Context, max_lines: int) -> None: context.selectable_log = MockSelectableLog(max_lines=max_lines) -@given("a SelectableLog widget with content \"{content}\" on line {line_num:d}") +@given('a SelectableLog widget with content "{content}" on line {line_num:d}') def step_selectable_log_with_content(context: Context, content: str, line_num: int) -> None: """Create a SelectableLog widget with specific content. @@ -103,7 +103,7 @@ def step_selectable_log_with_content(context: Context, content: str, line_num: i context.selectable_log.has_focus = True -@given("a SelectableLog widget with content \"{content}\"") +@given('a SelectableLog widget with content "{content}"') def step_selectable_log_with_content_simple(context: Context, content: str) -> None: """Create a SelectableLog widget with specific content (simple variant). @@ -134,7 +134,7 @@ def step_widget_has_focus(context: Context) -> None: context.selectable_log.has_focus = True -@given("a SelectableLog widget with selected text \"{text}\"") +@given('a SelectableLog widget with selected text "{text}"') def step_selectable_log_with_selection(context: Context, text: str) -> None: """Create a SelectableLog with specific text selected. @@ -195,7 +195,7 @@ def step_widget_scrolled_to_top(context: Context) -> None: context.selectable_log.scroll_position = 0 -@when("text content \"{content}\" is written") +@when('text content "{content}" is written') def step_write_text_content(context: Context, content: str) -> None: """Write text content to SelectableLog. @@ -209,7 +209,7 @@ def step_write_text_content(context: Context, content: str) -> None: context.selectable_log.write(content) -@when("ANSI colored text \"{content}\" is written") +@when('ANSI colored text "{content}" is written') def step_write_ansi_colored_text(context: Context, content: str) -> None: """Write ANSI colored text to SelectableLog. @@ -225,7 +225,7 @@ def step_write_ansi_colored_text(context: Context, content: str) -> None: context.last_written_content = decoded_content -@when("ANSI control sequence \"{sequence}\" is written") +@when('ANSI control sequence "{sequence}" is written') def step_write_ansi_control_sequence(context: Context, sequence: str) -> None: """Write ANSI control sequence to SelectableLog. @@ -330,7 +330,7 @@ def step_new_content_written(context: Context) -> None: context.selectable_log.write("New log line") -@when("log message \"{message}\" is emitted") +@when('log message "{message}" is emitted') def step_log_message_emitted(context: Context, message: str) -> None: """Emit a log message to be captured by TUI. @@ -346,7 +346,7 @@ def step_log_message_emitted(context: Context, message: str) -> None: context.tui_log_widget.write(message) -@then("the widget displays \"{content}\"") +@then('the widget displays "{content}"') def step_widget_displays_content(context: Context, content: str) -> None: """Verify widget displays expected content. @@ -538,7 +538,7 @@ def step_text_copied_to_clipboard(context: Context) -> None: assert context.selectable_log.clipboard_content is not None, "Clipboard is empty" -@then("notification \"{notification}\" is shown") +@then('notification "{notification}" is shown') def step_notification_shown(context: Context, notification: str) -> None: """Verify notification is displayed. diff --git a/tests/integration/features/tui_context_menu.feature b/tests/integration/features/tui_context_menu.feature new file mode 100644 index 0000000..841c97d --- /dev/null +++ b/tests/integration/features/tui_context_menu.feature @@ -0,0 +1,82 @@ +Feature: Context Menu Widget + +@smoke +Scenario: Context menu appears on right-click with selected text + Given a SelectableLog widget with selected text "192.168.1.1" + When user right-clicks on SelectableLog + Then context menu is visible + And menu has Copy, Search, Clear items + And Copy menu item is enabled + +@smoke +Scenario: Context menu adjusts position within viewport + Given a SelectableLog widget with content "Test content" + When user right-clicks near viewport edge + Then context menu is adjusted within viewport + And context menu is positioned within screen bounds + +@smoke +Scenario: Copy action copies selected text to clipboard + Given a SelectableLog widget with selected text "192.168.1.1" + And context menu is open + When user activates Copy from menu + Then text is copied to clipboard + And notification "Copied to clipboard" is shown + And context menu closes + +@smoke +Scenario: Copy action disabled when no text selected + Given a SelectableLog widget with no selection + And context menu is open + Then Copy menu item is disabled + And selecting Copy does nothing + +@smoke +Scenario: Clear action removes all log content + Given a SelectableLog widget with 10 lines of content + And context menu is open + When user activates Clear from menu + Then widget displays no lines + And context menu closes + +@smoke +Scenario: Search action posts ContextMenuSearch message + Given a SelectableLog widget with content "searchable" + And context menu is open + When user activates Search from menu + Then ContextMenuSearch message is posted + And context menu closes + +@smoke +Scenario: Escape key closes context menu without action + Given a SelectableLog widget with content "test" + And context menu is open + When user presses Escape + Then context menu closes + And no menu action is performed + +@smoke +Scenario: Click outside menu closes context menu + Given a SelectableLog widget with content "test" + And context menu is open + When user clicks outside the menu area + Then context menu closes + +@smoke +Scenario: Arrow keys navigate menu items + Given a SelectableLog widget with content "test" + And context menu is open with Copy highlighted + When user presses Down arrow + Then Search item is highlighted + When user presses Down arrow + Then Clear item is highlighted + When user presses Down arrow + Then Copy item is highlighted + +@smoke +Scenario: Enter key activates highlighted menu item + Given a SelectableLog widget with 5 lines of content + And context menu is open with Clear highlighted + When user presses Enter + Then all content is cleared + And context menu closes diff --git a/tests/unit/fakes/fake_ssh_manager.py b/tests/unit/fakes/fake_ssh_manager.py index 73af4c3..876eadc 100644 --- a/tests/unit/fakes/fake_ssh_manager.py +++ b/tests/unit/fakes/fake_ssh_manager.py @@ -87,7 +87,7 @@ def execute_command(self, command: str) -> int: logger.info("Fake SSH: executing command: %s", command) - exit_match = re.search(r'\bexit\s+(\d+)', command) + exit_match = re.search(r"\bexit\s+(\d+)", command) if exit_match: return int(exit_match.group(1)) return 0 @@ -110,7 +110,7 @@ def execute_command_raw(self, command: str) -> int: logger.info("Fake SSH: executing raw command: %s", command) - exit_match = re.search(r'\bexit\s+(\d+)', command) + exit_match = re.search(r"\bexit\s+(\d+)", command) if exit_match: return int(exit_match.group(1)) return 0 @@ -246,7 +246,7 @@ def execute_interactive(self, command: str | None = None) -> int: if command.strip() == "tty": logger.info("/dev/pts/1") - exit_match = re.search(r'\bexit\s+(\d+)', command) + exit_match = re.search(r"\bexit\s+(\d+)", command) if exit_match: return int(exit_match.group(1)) return 0 diff --git a/tests/unit/test_ssh.py b/tests/unit/test_ssh.py index 4ec0ba5..f08c169 100644 --- a/tests/unit/test_ssh.py +++ b/tests/unit/test_ssh.py @@ -746,9 +746,7 @@ def test_setup_terminal_saves_attributes( mock_setcbreak.assert_called_once() @patch("campers.services.ssh.termios.tcsetattr") - def test_restore_terminal_restores_attributes( - self, mock_tcsetattr: MagicMock - ) -> None: + def test_restore_terminal_restores_attributes(self, mock_tcsetattr: MagicMock) -> None: """Test _restore_terminal restores original attributes.""" mock_channel = MagicMock() session = InteractiveSession(mock_channel) @@ -791,9 +789,7 @@ def test_setup_sigwinch_installs_handler( mock_channel.resize_pty.assert_called_once_with(width=80, height=24) @patch("campers.services.ssh.signal.signal") - def test_restore_sigwinch_restores_handler( - self, mock_signal: MagicMock - ) -> None: + def test_restore_sigwinch_restores_handler(self, mock_signal: MagicMock) -> None: """Test _restore_sigwinch restores original SIGWINCH handler.""" mock_channel = MagicMock() session = InteractiveSession(mock_channel) @@ -814,9 +810,7 @@ def test_restore_sigwinch_no_handler(self) -> None: mock_signal.assert_not_called() @patch("campers.services.ssh.get_terminal_size") - def test_resize_pty_sends_size_to_channel( - self, mock_get_terminal_size: MagicMock - ) -> None: + def test_resize_pty_sends_size_to_channel(self, mock_get_terminal_size: MagicMock) -> None: """Test _resize_pty sends terminal size to channel.""" mock_channel = MagicMock() session = InteractiveSession(mock_channel) @@ -827,9 +821,7 @@ def test_resize_pty_sends_size_to_channel( mock_channel.resize_pty.assert_called_once_with(width=100, height=30) @patch("campers.services.ssh.get_terminal_size") - def test_resize_pty_handles_exception( - self, mock_get_terminal_size: MagicMock - ) -> None: + def test_resize_pty_handles_exception(self, mock_get_terminal_size: MagicMock) -> None: """Test _resize_pty handles exceptions silently.""" mock_channel = MagicMock() session = InteractiveSession(mock_channel) From e9bf843c1d7c9f6dd9a18897894c4b8526c7547c Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Fri, 19 Dec 2025 15:03:45 +0100 Subject: [PATCH 03/14] Add Vim-like search to TUI log widget with highlighting - Implement SearchInput widget for log searching - Add search state, highlighting, and navigation to SelectableLog - Support opening search with /, Ctrl+F, or context menu - Highlight matches (current in orange, others in yellow) - Enable navigation with n/N, F3/Shift+F3, and wrap-around - Add integration and unit tests for search behavior --- campers/tui/app.py | 44 +- campers/tui/widgets/__init__.py | 3 +- campers/tui/widgets/search_input.py | 164 ++++++ campers/tui/widgets/selectable_log.py | 180 +++++- .../features/steps/tui_context_menu_steps.py | 5 + .../features/steps/tui_live_updates_steps.py | 41 ++ .../features/steps/tui_vim_search_steps.py | 538 ++++++++++++++++++ .../features/tui_vim_search.feature | 115 ++++ tests/unit/test_selectable_log.py | 104 ++++ 9 files changed, 1186 insertions(+), 8 deletions(-) create mode 100644 campers/tui/widgets/search_input.py create mode 100644 tests/integration/features/steps/tui_vim_search_steps.py create mode 100644 tests/integration/features/tui_vim_search.feature diff --git a/campers/tui/app.py b/campers/tui/app.py index 8b4733c..8a4608c 100644 --- a/campers/tui/app.py +++ b/campers/tui/app.py @@ -32,6 +32,7 @@ from campers.tui.styling import TUI_CSS from campers.tui.terminal import detect_terminal_background from campers.tui.widgets.context_menu import ContextMenu +from campers.tui.widgets.search_input import SearchClosed, SearchInput, SearchQueryChanged from campers.tui.widgets.selectable_log import SelectableLog if TYPE_CHECKING: @@ -117,7 +118,7 @@ def compose(self) -> ComposeResult: Container Status panel container with static widgets Container - Log panel container with log widget + Log panel container with log widget and search input ContextMenu Context menu for SelectableLog actions """ @@ -135,6 +136,7 @@ def compose(self) -> ComposeResult: yield Static("", id=widgets.WidgetID.PUBLIC_PORTS, classes="hidden") with Container(id="log-panel"): yield SelectableLog() + yield SearchInput() yield ContextMenu() def on_mount(self) -> None: @@ -195,10 +197,48 @@ async def on_context_menu_item_selected(self, message: ContextMenu.ItemSelected) if message.action == "copy" and hasattr(target, "action_copy"): target.action_copy() elif message.action == "search": - pass + search_input = self.query_one(SearchInput) + search_input.show() elif message.action == "clear" and hasattr(target, "clear"): target.clear() + async def on_search_query_changed(self, message: SearchQueryChanged) -> None: + """Handle search query change. + + Updates the log widget with the new search results and updates + the match count display. + + Parameters + ---------- + message : SearchQueryChanged + Message containing the new search query + """ + log_widget = self.query_one(SelectableLog) + log_widget.start_search(message.query) + + search_input = self.query_one(SearchInput) + search_input.update_match_count( + log_widget.current_match_index, len(log_widget.search_matches) + ) + + async def on_search_closed(self, message: SearchClosed) -> None: + """Handle search input closed. + + Clears search if keep_matches is False, otherwise preserves matches + for continued navigation. Returns focus to the log widget. + + Parameters + ---------- + message : SearchClosed + Message indicating whether to keep matches + """ + log_widget = self.query_one(SelectableLog) + + if not message.keep_matches: + log_widget.clear_search() + + log_widget.focus() + def check_for_updates(self) -> None: """Check queue for updates and update widgets accordingly. diff --git a/campers/tui/widgets/__init__.py b/campers/tui/widgets/__init__.py index 349876d..c6f0299 100644 --- a/campers/tui/widgets/__init__.py +++ b/campers/tui/widgets/__init__.py @@ -1,6 +1,7 @@ """TUI widgets module for campers.""" from campers.tui.widgets.context_menu import ContextMenu +from campers.tui.widgets.search_input import SearchInput from campers.tui.widgets.selectable_log import SelectableLog from campers.tui.widgets.selection import Selection @@ -20,4 +21,4 @@ class WidgetID: PUBLIC_PORTS = "public-ports-widget" -__all__ = ["ContextMenu", "SelectableLog", "Selection", "WidgetID"] +__all__ = ["ContextMenu", "SearchInput", "SelectableLog", "Selection", "WidgetID"] diff --git a/campers/tui/widgets/search_input.py b/campers/tui/widgets/search_input.py new file mode 100644 index 0000000..3675857 --- /dev/null +++ b/campers/tui/widgets/search_input.py @@ -0,0 +1,164 @@ +"""Search input widget for TUI log searching.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Container +from textual.message import Message +from textual.widgets import Input, Static + + +class SearchQueryChanged(Message): + """Message posted when search query changes. + + Parameters + ---------- + query : str + The current search query text + """ + + def __init__(self, query: str) -> None: + """Initialize SearchQueryChanged message. + + Parameters + ---------- + query : str + The current search query text + """ + self.query = query + super().__init__() + + +class SearchClosed(Message): + """Message posted when search input is closed. + + Parameters + ---------- + keep_matches : bool + Whether to preserve matches when closing + """ + + def __init__(self, keep_matches: bool) -> None: + """Initialize SearchClosed message. + + Parameters + ---------- + keep_matches : bool + Whether to preserve matches when closing + """ + self.keep_matches = keep_matches + super().__init__() + + +class SearchInput(Container): + """Container widget for search input and match count display. + + This widget displays a search input field and a match counter. It docks + to the bottom of its parent container and can be shown/hidden dynamically. + """ + + DEFAULT_CSS = """ + SearchInput { + dock: bottom; + height: 1; + background: #383838; + display: none; + layout: horizontal; + } + + SearchInput.visible { + display: block; + } + + SearchInput Input { + width: 1fr; + } + + SearchInput .match-count { + width: auto; + padding: 0 2; + color: #ffffff; + } + """ + + def __init__(self, **kwargs) -> None: + """Initialize SearchInput widget. + + Parameters + ---------- + **kwargs + Additional keyword arguments passed to Container + """ + super().__init__(**kwargs) + + def compose(self) -> ComposeResult: + """Compose child widgets. + + Yields + ------ + Input + Text input field for search query + Static + Static widget for displaying match count + """ + yield Input(placeholder="Search...", id="search-query") + yield Static("", id="match-count", classes="match-count") + + def show(self) -> None: + """Show the search input and focus the text field. + + Adds the 'visible' class to display the widget and focuses the + search query input field. + """ + self.add_class("visible") + self.query_one("#search-query", Input).focus() + + def hide(self, keep_matches: bool = False) -> None: + """Hide the search input and post SearchClosed message. + + Parameters + ---------- + keep_matches : bool + Whether to preserve matches when closing + """ + self.remove_class("visible") + self.query_one("#search-query", Input).value = "" + self.post_message(SearchClosed(keep_matches=keep_matches)) + + def update_match_count(self, current: int, total: int) -> None: + """Update the match counter display. + + Parameters + ---------- + current : int + Index of current match (0-based) + total : int + Total number of matches found + """ + text = "No matches" if total == 0 or current < 0 else f"{current + 1} of {total}" + self.query_one("#match-count", Static).update(text) + + def on_input_changed(self, event: Input.Changed) -> None: + """Handle search input change. + + Parameters + ---------- + event : Input.Changed + Input changed event from the search query field + """ + self.post_message(SearchQueryChanged(query=event.value)) + + def on_key(self, event) -> None: + """Handle key press events. + + Parameters + ---------- + event + Key event from Textual + """ + if event.key == "escape": + self.hide(keep_matches=False) + event.stop() + elif event.key == "enter": + self.hide(keep_matches=True) + event.stop() diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index 9ea557e..ff6dcc5 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -3,11 +3,13 @@ from __future__ import annotations import logging +import re +from dataclasses import dataclass from typing import ClassVar from rich.style import Style from rich.text import Text -from textual.geometry import Offset, Size +from textual.geometry import Offset, Region, Size, Spacing from textual.scroll_view import ScrollView from textual.strip import Strip @@ -16,12 +18,32 @@ logger = logging.getLogger(__name__) +@dataclass +class SearchMatch: + """Represents a single search match in the log. + + Parameters + ---------- + line : int + Line index of the match + start : int + Starting column of the match + end : int + Ending column of the match + """ + + line: int + start: int + end: int + + class SelectableLog(ScrollView, can_focus=True): - """A scrollable log widget with text selection and clipboard support. + """A scrollable log widget with text selection, clipboard, and search support. This widget displays log content and allows users to select text with the mouse and copy it to the clipboard. It sanitizes ANSI escape codes - while preserving color information. + while preserving color information. It also supports vim-like search + with match highlighting and navigation. Parameters ---------- @@ -38,14 +60,28 @@ class SelectableLog(ScrollView, can_focus=True): Maximum number of lines to store in buffer selection : Selection | None Current text selection, or None if no selection + search_query : str | None + Current search query, or None if no search active + search_matches : list[SearchMatch] + List of all search matches found + current_match_index : int + Index of current match in matches list """ BINDINGS: ClassVar = [ ("ctrl+c", "copy", "Copy"), ("ctrl+a", "select_all", "Select All"), + ("slash", "open_search", "Search"), + ("ctrl+f", "open_search", "Search"), + ("n", "next_match", "Next Match"), + ("N", "previous_match", "Previous Match"), + ("f3", "next_match", "Next Match"), + ("shift+f3", "previous_match", "Previous Match"), ] SELECTION_STYLE: ClassVar = Style(bgcolor="blue", color="white") + MATCH_STYLE: ClassVar = Style(bgcolor="yellow", color="black") + CURRENT_MATCH_STYLE: ClassVar = Style(bgcolor="#ff8c00", color="black", bold=True) def __init__(self, max_lines: int = 5000, **kwargs) -> None: """Initialize SelectableLog widget. @@ -62,6 +98,9 @@ def __init__(self, max_lines: int = 5000, **kwargs) -> None: self.max_lines = max_lines self.selection: Selection | None = None self._selecting = False + self.search_query: str | None = None + self.search_matches: list[SearchMatch] = [] + self.current_match_index: int = 0 def write(self, content: str | Text) -> None: """Write content to the log widget. @@ -92,8 +131,9 @@ def write(self, content: str | Text) -> None: def render_line(self, y: int) -> Strip: """Render a single line of the log. - Accounts for scroll offset and applies selection styling if the line - contains selected content. + Accounts for scroll offset and applies match and selection styling. + Match highlighting is applied first, then selection highlighting takes + precedence where they overlap. Parameters ---------- @@ -113,6 +153,16 @@ def render_line(self, y: int) -> Strip: line = self.lines[line_index].copy() + if self.search_matches: + for i, match in enumerate(self.search_matches): + if match.line == line_index: + style = ( + self.CURRENT_MATCH_STYLE + if i == self.current_match_index + else self.MATCH_STYLE + ) + line.stylize(style, match.start, match.end) + if self.selection: start, end = self.selection.normalized if start[0] <= line_index <= end[0]: @@ -282,6 +332,126 @@ def action_select_all(self) -> None: self.selection = Selection(start=(0, 0), end=(last_line, last_col)) self.refresh() + def action_open_search(self) -> None: + """Open the search input widget. + + Shows the SearchInput widget and focuses it for user input. + """ + from campers.tui.widgets.search_input import SearchInput + + search_input = self.app.query_one(SearchInput) + search_input.show() + + def find_matches(self, query: str) -> list[SearchMatch]: + """Find all matches of query in the log content. + + Performs case-insensitive search treating the query as literal text. + Matches are returned with their line and column positions. + + Parameters + ---------- + query : str + Search query (treated as literal text, not regex) + + Returns + ------- + list[SearchMatch] + List of SearchMatch objects with positions + """ + if not query: + return [] + + matches = [] + pattern = re.compile(re.escape(query), re.IGNORECASE) + + for line_idx, line in enumerate(self.lines): + for match in pattern.finditer(line.plain): + matches.append(SearchMatch(line=line_idx, start=match.start(), end=match.end())) + + return matches + + def start_search(self, query: str) -> None: + """Start a new search with the given query. + + Updates search state, finds matches, resets current match index to 0, + and scrolls to the first match if any are found. + + Parameters + ---------- + query : str + Search query text + """ + self.search_query = query + self.search_matches = self.find_matches(query) + self.current_match_index = 0 if self.search_matches else -1 + + if self.search_matches: + self._scroll_to_current_match() + + self.refresh() + + def action_next_match(self) -> None: + """Navigate to the next search match. + + Advances current_match_index and wraps around when reaching the end. + Does nothing if no matches are found. + """ + if not self.search_matches: + return + self.current_match_index = (self.current_match_index + 1) % len(self.search_matches) + self._scroll_to_current_match() + self._notify_match_count_update() + self.refresh() + + def action_previous_match(self) -> None: + """Navigate to the previous search match. + + Decrements current_match_index and wraps around when reaching the start. + Does nothing if no matches are found. + """ + if not self.search_matches: + return + self.current_match_index = (self.current_match_index - 1) % len(self.search_matches) + self._scroll_to_current_match() + self._notify_match_count_update() + self.refresh() + + def _notify_match_count_update(self) -> None: + """Update the match count display in SearchInput widget. + + Safely queries for SearchInput and updates match count if available. + """ + try: + from campers.tui.widgets.search_input import SearchInput + + search_input = self.app.query_one(SearchInput) + search_input.update_match_count(self.current_match_index, len(self.search_matches)) + except Exception: + logger.debug("SearchInput not available for match count update") + + def _scroll_to_current_match(self) -> None: + """Scroll the view to show the current match. + + Uses scroll_to_region with padding to center the match in the viewport. + Does nothing if there are no matches or current_match_index is invalid. + """ + if not self.search_matches or self.current_match_index < 0: + return + match = self.search_matches[self.current_match_index] + region = Region(x=match.start, y=match.line, width=match.end - match.start, height=1) + self.scroll_to_region(region, spacing=Spacing(top=3, bottom=3)) + + def clear_search(self) -> None: + """Clear all search state. + + Resets search_query, search_matches, and current_match_index. + Triggers a refresh to remove highlighting. + """ + self.search_query = None + self.search_matches = [] + self.current_match_index = 0 + self.refresh() + def clear(self) -> None: """Clear all lines from the log widget. diff --git a/tests/integration/features/steps/tui_context_menu_steps.py b/tests/integration/features/steps/tui_context_menu_steps.py index 68d8b5c..720c748 100644 --- a/tests/integration/features/steps/tui_context_menu_steps.py +++ b/tests/integration/features/steps/tui_context_menu_steps.py @@ -298,6 +298,11 @@ def step_user_activates_menu_item(context: Context, item: str) -> None: if item == "Search": context.context_menu_search_posted = True + from tests.integration.features.steps.tui_vim_search_steps import MockSearchInput + if not hasattr(context, "search_input"): + context.search_input = MockSearchInput() + context.search_input.visible = True + context.search_input.has_focus = True context.context_menu.activate_item(index) diff --git a/tests/integration/features/steps/tui_live_updates_steps.py b/tests/integration/features/steps/tui_live_updates_steps.py index 21b199e..e779b5e 100644 --- a/tests/integration/features/steps/tui_live_updates_steps.py +++ b/tests/integration/features/steps/tui_live_updates_steps.py @@ -253,6 +253,47 @@ def step_user_presses_key(context: Context, key: str) -> None: """ context.key_pressed = key + if key == "/": + if not hasattr(context, "search_input"): + from tests.integration.features.steps.tui_vim_search_steps import MockSearchInput + context.search_input = MockSearchInput() + context.search_input.visible = True + context.search_input.has_focus = True + elif key == "n" and hasattr(context, "selectable_log"): + context.selectable_log.next_match() + elif key == "N" and hasattr(context, "selectable_log"): + context.selectable_log.previous_match() + elif key == "F3" and hasattr(context, "selectable_log"): + context.selectable_log.next_match() + elif key == "Shift+F3" and hasattr(context, "selectable_log"): + context.selectable_log.previous_match() + elif key == "Escape": + if hasattr(context, "search_input"): + context.search_input.visible = False + if hasattr(context, "selectable_log"): + context.selectable_log.highlighting_cleared = True + context.selectable_log.search_matches = [] + if hasattr(context, "context_menu"): + context.context_menu.hide() + context.escape_pressed = True + elif key == "Enter": + if hasattr(context, "search_input"): + context.search_input.visible = False + if hasattr(context, "context_menu"): + item_index = context.context_menu.highlighted_index + item_name = context.context_menu.items[item_index] + if item_name not in context.context_menu.disabled_items: + if item_name == "Clear" and hasattr(context, "selectable_log"): + context.selectable_log.clear() + elif item_name == "Copy" and hasattr(context, "selectable_log"): + context.selectable_log.copy_to_clipboard() + elif key == "Ctrl+F": + if not hasattr(context, "search_input"): + from tests.integration.features.steps.tui_vim_search_steps import MockSearchInput + context.search_input = MockSearchInput() + context.search_input.visible = True + context.search_input.has_focus = True + @then("graceful shutdown is initiated") def step_graceful_shutdown_initiated(context: Context) -> None: diff --git a/tests/integration/features/steps/tui_vim_search_steps.py b/tests/integration/features/steps/tui_vim_search_steps.py new file mode 100644 index 0000000..37517d7 --- /dev/null +++ b/tests/integration/features/steps/tui_vim_search_steps.py @@ -0,0 +1,538 @@ +"""Step definitions for TUI Vim-like search feature.""" + +from behave import given, then, when +from behave.runner import Context +from rich.text import Text + + +class MockSearchInput: + """Mock SearchInput widget for testing.""" + + def __init__(self) -> None: + self.visible = False + self.has_focus = False + self.query = "" + self.match_count_text = "" + self.matches_count = 0 + + +class MockSelectableLogSearch: + """Extended mock for SelectableLog supporting search functionality.""" + + def __init__(self, max_lines: int = 5000) -> None: + self.lines: list[Text] = [] + self.max_lines = max_lines + self.selection: tuple[tuple[int, int], tuple[int, int]] | None = None + self.clipboard_content: str | None = None + self.notification: str | None = None + self.scroll_position = 0 + self.has_focus = False + self.search_query: str | None = None + self.search_matches: list[tuple[int, int, int]] = [] + self.current_match_index: int = -1 + self.highlighting_cleared: bool = False + + def write(self, content: str | Text) -> None: + """Write content to widget. + + Parameters + ---------- + content : str | Text + Content to write + """ + text = Text.from_ansi(content) if isinstance(content, str) else content + + for line in text.split("\n"): + self.lines.append(line) + + if len(self.lines) > self.max_lines: + self.lines = self.lines[-self.max_lines :] + + def get_selected_text(self) -> str: + """Get currently selected text. + + Returns + ------- + str + Selected text or empty string + """ + if not self.selection: + return "" + + start, end = self.selection + if start > end: + start, end = end, start + + selected_lines = [] + for i in range(start[0], min(end[0] + 1, len(self.lines))): + line_text = self.lines[i].plain + if i == start[0] and i == end[0]: + selected_lines.append(line_text[start[1] : end[1]]) + elif i == start[0]: + selected_lines.append(line_text[start[1] :]) + elif i == end[0]: + selected_lines.append(line_text[: end[1]]) + else: + selected_lines.append(line_text) + + return "\n".join(selected_lines) + + def copy_to_clipboard(self) -> None: + """Copy selected text to clipboard.""" + text = self.get_selected_text() + if text: + self.clipboard_content = text + self.notification = "Copied to clipboard" + + def find_matches(self, query: str) -> list[tuple[int, int, int]]: + """Find all matches of query in content. + + Parameters + ---------- + query : str + Search query (case-insensitive literal text) + + Returns + ------- + list[tuple[int, int, int]] + List of (line_index, start_col, end_col) tuples + """ + import re + + if not query: + return [] + + matches = [] + pattern = re.compile(re.escape(query), re.IGNORECASE) + + for line_idx, line in enumerate(self.lines): + for match in pattern.finditer(line.plain): + matches.append((line_idx, match.start(), match.end())) + + return matches + + def start_search(self, query: str) -> None: + """Start search with given query. + + Parameters + ---------- + query : str + Search query + """ + self.search_query = query + self.search_matches = self.find_matches(query) + self.current_match_index = 0 if self.search_matches else -1 + self.highlighting_cleared = False + + def next_match(self) -> None: + """Navigate to next match with wrap-around.""" + if not self.search_matches: + return + self.current_match_index = (self.current_match_index + 1) % len(self.search_matches) + + def previous_match(self) -> None: + """Navigate to previous match with wrap-around.""" + if not self.search_matches: + return + self.current_match_index = (self.current_match_index - 1) % len(self.search_matches) + + def clear_search(self) -> None: + """Clear search state and highlighting.""" + self.search_query = None + self.search_matches = [] + self.current_match_index = -1 + self.highlighting_cleared = True + + +@given("search input is open") +def step_search_input_open(context: Context) -> None: + """Open search input. + + Parameters + ---------- + context : Context + Behave context + """ + if not hasattr(context, "search_input"): + context.search_input = MockSearchInput() + context.search_input.visible = True + context.search_input.has_focus = True + + +@given("search is active with {query} query") +def step_search_active_with_query(context: Context, query: str) -> None: + """Activate search with specific query. + + Parameters + ---------- + context : Context + Behave context + query : str + Search query + """ + if not hasattr(context, "selectable_log"): + context.selectable_log = MockSelectableLogSearch() + if not hasattr(context, "search_input"): + context.search_input = MockSearchInput() + + context.selectable_log.start_search(query) + context.search_input.visible = True + context.search_input.query = query + context.search_input.matches_count = len(context.selectable_log.search_matches) + + +@given('a SelectableLog widget with {num:d} lines of content containing "{text}"') +def step_widget_with_n_lines_containing(context: Context, num: int, text: str) -> None: + """Create SelectableLog with N lines each containing text. + + Parameters + ---------- + context : Context + Behave context + num : int + Number of lines + text : str + Text to include in each line + """ + if not hasattr(context, "selectable_log"): + context.selectable_log = MockSelectableLogSearch() + + for i in range(num): + context.selectable_log.write(f"Line {i}: {text}") + + +@given("search is active with current match at line {line_num:d}") +def step_search_active_at_line(context: Context, line_num: int) -> None: + """Set search active at specific line. + + Parameters + ---------- + context : Context + Behave context + line_num : int + Line number of current match + """ + if ( + hasattr(context, "selectable_log") + and context.selectable_log.search_matches + and line_num < len(context.selectable_log.search_matches) + ): + for i, match in enumerate(context.selectable_log.search_matches): + if match[0] == line_num: + context.selectable_log.current_match_index = i + break + + +@given("matches are highlighted") +def step_matches_highlighted(context: Context) -> None: + """Verify matches are highlighted. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "selectable_log"): + assert len(context.selectable_log.search_matches) > 0, "No matches to highlight" + assert not context.selectable_log.highlighting_cleared, "Highlighting was cleared" + + +@given("a SelectableLog widget with active search") +def step_widget_with_active_search(context: Context) -> None: + """Create SelectableLog with active search. + + Parameters + ---------- + context : Context + Behave context + """ + if not hasattr(context, "selectable_log"): + context.selectable_log = MockSelectableLogSearch() + context.selectable_log.write("error on line 1") + context.selectable_log.write("error on line 2") + context.selectable_log.write("warning on line 3") + + context.selectable_log.start_search("error") + if not hasattr(context, "search_input"): + context.search_input = MockSearchInput() + context.search_input.visible = True + + +@given("current match is at index {index:d}") +def step_current_match_at_index(context: Context, index: int) -> None: + """Set current match index. + + Parameters + ---------- + context : Context + Behave context + index : int + Match index + """ + if hasattr(context, "selectable_log"): + context.selectable_log.current_match_index = index + + +@given("a SelectableLog widget with matches at lines {line_nums}") +def step_widget_with_matches_at_lines(context: Context, line_nums: str) -> None: + """Create SelectableLog with matches at specific lines. + + Parameters + ---------- + context : Context + Behave context + line_nums : str + Comma-separated line numbers + """ + if not hasattr(context, "selectable_log"): + context.selectable_log = MockSelectableLogSearch() + + lines = [int(n.strip()) for n in line_nums.split(" and ")] + max_line = max(lines) if lines else 0 + + for i in range(max_line + 1): + if i in lines: + context.selectable_log.write(f"match content line {i}") + else: + context.selectable_log.write(f"other content line {i}") + + context.selectable_log.start_search("match") + + +@when('user types "{text}"') +def step_user_types(context: Context, text: str) -> None: + """Simulate user typing in search input. + + Parameters + ---------- + context : Context + Behave context + text : str + Text to type + """ + if hasattr(context, "selectable_log"): + context.selectable_log.start_search(text) + + if hasattr(context, "search_input"): + context.search_input.query = text + matches_count = ( + len(context.selectable_log.search_matches) + if hasattr(context, "selectable_log") + else 0 + ) + context.search_input.matches_count = matches_count + + + + +@then("search input appears") +def step_search_input_appears(context: Context) -> None: + """Verify search input appears. + + Parameters + ---------- + context : Context + Behave context + """ + assert hasattr(context, "search_input"), "Search input not created" + assert context.search_input.visible, "Search input is not visible" + + +@then("search input has focus") +def step_search_input_has_focus(context: Context) -> None: + """Verify search input has focus. + + Parameters + ---------- + context : Context + Behave context + """ + assert hasattr(context, "search_input"), "Search input not created" + assert context.search_input.has_focus, "Search input does not have focus" + + +@then("{num:d} matches are found") +def step_n_matches_found(context: Context, num: int) -> None: + """Verify N matches were found. + + Parameters + ---------- + context : Context + Behave context + num : int + Expected number of matches + """ + assert hasattr(context, "selectable_log"), "SelectableLog not created" + actual_matches = len(context.selectable_log.search_matches) + assert actual_matches == num, f"Expected {num} matches, found {actual_matches}" + + +@then('match count shows "{expected_text}"') +def step_match_count_shows(context: Context, expected_text: str) -> None: + """Verify match count display. + + Parameters + ---------- + context : Context + Behave context + expected_text : str + Expected count text + """ + if hasattr(context, "search_input"): + if "of" in expected_text: + current, total = expected_text.split(" of ") + total_matches = int(total) + actual_count = ( + len(context.selectable_log.search_matches) + if hasattr(context, "selectable_log") + else 0 + ) + assert ( + actual_count == total_matches + ), f"Expected {total_matches} matches, got {actual_count}" + else: + assert ( + expected_text in ["No matches"] + ), f"Expected '{expected_text}'" + + +@then("first match is highlighted with orange") +def step_first_match_orange(context: Context) -> None: + """Verify first match is highlighted with orange. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "selectable_log"): + assert context.selectable_log.current_match_index == 0, "First match should be current" + + +@then("other matches are highlighted with yellow") +def step_other_matches_yellow(context: Context) -> None: + """Verify non-current matches are highlighted with yellow. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "selectable_log"): + assert len(context.selectable_log.search_matches) > 1, "Expected multiple matches" + + +@then("current match changes to line {line_num:d}") +def step_current_match_at_line(context: Context, line_num: int) -> None: + """Verify current match is at specific line. + + Parameters + ---------- + context : Context + Behave context + line_num : int + Expected line number + """ + if ( + hasattr(context, "selectable_log") + and context.selectable_log.current_match_index >= 0 + ): + match = context.selectable_log.search_matches[ + context.selectable_log.current_match_index + ] + assert ( + match[0] == line_num + ), f"Expected match at line {line_num}, got {match[0]}" + + +@then("current match wraps to line {line_num:d}") +def step_current_match_wraps(context: Context, line_num: int) -> None: + """Verify current match wraps to specific line. + + Parameters + ---------- + context : Context + Behave context + line_num : int + Expected line number after wrap + """ + if hasattr(context, "selectable_log"): + match = context.selectable_log.search_matches[context.selectable_log.current_match_index] + assert match[0] == line_num, f"Expected wrap to line {line_num}, got {match[0]}" + + +@then("search input closes") +def step_search_input_closes(context: Context) -> None: + """Verify search input closes. + + Parameters + ---------- + context : Context + Behave context + """ + assert hasattr(context, "search_input"), "Search input not created" + assert not context.search_input.visible, "Search input should be closed" + + +@then("all match highlighting is removed") +def step_all_highlighting_removed(context: Context) -> None: + """Verify all match highlighting is removed. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "selectable_log"): + assert context.selectable_log.highlighting_cleared, "Highlighting should be cleared" + assert len(context.selectable_log.search_matches) == 0, "Matches should be cleared" + + +@then("match highlighting remains visible") +def step_highlighting_remains(context: Context) -> None: + """Verify match highlighting remains. + + Parameters + ---------- + context : Context + Behave context + """ + if hasattr(context, "selectable_log"): + assert len(context.selectable_log.search_matches) > 0, "Matches should still be present" + assert not context.selectable_log.highlighting_cleared, "Highlighting should not be cleared" + + +@then("current match advances to index {index:d}") +def step_match_advances_to_index(context: Context, index: int) -> None: + """Verify current match is at index. + + Parameters + ---------- + context : Context + Behave context + index : int + Expected match index + """ + if hasattr(context, "selectable_log"): + current_index = context.selectable_log.current_match_index + assert current_index == index, ( + f"Expected current match at index {index}, got {current_index}" + ) + + +@then("current match moves back to index {index:d}") +def step_match_moves_back_to_index(context: Context, index: int) -> None: + """Verify current match moved back to index. + + Parameters + ---------- + context : Context + Behave context + index : int + Expected match index + """ + if hasattr(context, "selectable_log"): + current_index = context.selectable_log.current_match_index + assert current_index == index, ( + f"Expected current match at index {index}, got {current_index}" + ) diff --git a/tests/integration/features/tui_vim_search.feature b/tests/integration/features/tui_vim_search.feature new file mode 100644 index 0000000..32f3b92 --- /dev/null +++ b/tests/integration/features/tui_vim_search.feature @@ -0,0 +1,115 @@ +Feature: TUI Vim-like Search + +@smoke +Scenario: Search opens with slash key + Given a SelectableLog widget with content "error line" + And the widget has focus + When user presses "/" key + Then search input appears + And search input has focus + +@smoke +Scenario: Search opens with Ctrl+F + Given a SelectableLog widget with content "warning line" + And the widget has focus + When user presses "Ctrl+F" key + Then search input appears + And search input has focus + +@smoke +Scenario: Search opens from context menu + Given a SelectableLog widget with content "debug line" + And context menu is open + When user activates Search from menu + Then search input appears + And search input has focus + +@smoke +Scenario: Matches are highlighted as user types + Given a SelectableLog widget with content "error" on line 0 + And a SelectableLog widget with content "error" on line 5 + And search input is open + When user types "error" + Then 2 matches are found + And match count shows "1 of 2" + +@smoke +Scenario: Current match has distinct highlighting + Given a SelectableLog widget with 3 lines of content containing "warning" + And search is active with "warning" query + Then first match is highlighted with orange + And other matches are highlighted with yellow + +@smoke +Scenario: Navigate to next match with n key + Given a SelectableLog widget with matches at lines 0 and 5 + And search is active with current match at line 0 + When user presses "n" key + Then current match changes to line 5 + +@smoke +Scenario: Navigate to previous match with N key + Given a SelectableLog widget with matches at lines 0 and 5 + And search is active with current match at line 5 + When user presses "N" key + Then current match changes to line 0 + +@smoke +Scenario: Match navigation wraps around + Given a SelectableLog widget with matches at lines 0 and 5 + And search is active with current match at line 5 + When user presses "n" key + Then current match wraps to line 0 + +@smoke +Scenario: Escape closes search and clears highlighting + Given a SelectableLog widget with active search + And matches are highlighted + When user presses "Escape" key + Then search input closes + And all match highlighting is removed + +@smoke +Scenario: Enter closes search but preserves highlighting + Given a SelectableLog widget with active search + And matches are highlighted + When user presses "Enter" key + Then search input closes + And match highlighting remains visible + +@smoke +Scenario: No matches displays appropriate message + Given a SelectableLog widget with content "hello world" + And search input is open + When user types "xyz" + Then match count shows "No matches" + +@smoke +Scenario: Search is case-insensitive + Given a SelectableLog widget with content "Error" + And a SelectableLog widget with content "ERROR" + And a SelectableLog widget with content "error" + And search input is open + When user types "error" + Then 3 matches are found + +@smoke +Scenario: Special characters are searched literally + Given a SelectableLog widget with content "[a-z]+" + And search input is open + When user types "[a-z]+" + Then 1 match is found + +@smoke +Scenario: F3 navigates to next match + Given a SelectableLog widget with active search + And current match is at index 0 + When user presses "F3" key + Then current match advances to index 1 + +@smoke +Scenario: Shift+F3 navigates to previous match + Given a SelectableLog widget with active search + And current match is at index 1 + When user presses "Shift+F3" key + Then current match moves back to index 0 diff --git a/tests/unit/test_selectable_log.py b/tests/unit/test_selectable_log.py index 7ab949d..a333cbb 100644 --- a/tests/unit/test_selectable_log.py +++ b/tests/unit/test_selectable_log.py @@ -234,3 +234,107 @@ def test_selection_normalized_on_empty_log(self) -> None: start, end = selection.normalized assert start == (0, 0) assert end == (1, 5) + + +class TestSelectableLogSearch: + """Tests for search functionality in SelectableLog.""" + + def test_find_matches_empty_query(self) -> None: + """Test find_matches returns empty list for empty query.""" + log = SelectableLog() + log.write("error line") + matches = log.find_matches("") + assert matches == [] + + def test_find_matches_no_matches(self) -> None: + """Test find_matches returns empty list when query not found.""" + log = SelectableLog() + log.write("warning line") + matches = log.find_matches("error") + assert matches == [] + + def test_find_matches_single_match(self) -> None: + """Test find_matches finds one occurrence.""" + log = SelectableLog() + log.write("error line") + matches = log.find_matches("error") + assert len(matches) == 1 + assert matches[0].line == 0 + assert matches[0].start == 0 + assert matches[0].end == 5 + + def test_find_matches_multiple_matches(self) -> None: + """Test find_matches finds all occurrences.""" + log = SelectableLog() + log.write("error\nerror\nerror") + matches = log.find_matches("error") + assert len(matches) == 3 + assert all(m.line >= 0 for m in matches) + assert all(m.start == 0 for m in matches) + assert all(m.end == 5 for m in matches) + + def test_find_matches_case_insensitive(self) -> None: + """Test find_matches is case-insensitive.""" + log = SelectableLog() + log.write("error\nError\nERROR") + matches = log.find_matches("error") + assert len(matches) == 3 + + def test_find_matches_special_characters(self) -> None: + """Test find_matches treats special characters literally.""" + log = SelectableLog() + log.write("test [a-z]+ pattern") + matches = log.find_matches("[a-z]+") + assert len(matches) == 1 + assert matches[0].start == 5 + assert matches[0].end == 11 + + def test_start_search_initializes_state(self) -> None: + """Test start_search initializes search state.""" + log = SelectableLog() + log.write("error\nerror\nerror") + log.start_search("error") + assert log.search_query == "error" + assert len(log.search_matches) == 3 + assert log.current_match_index == 0 + + def test_start_search_empty_query(self) -> None: + """Test start_search handles empty query gracefully.""" + log = SelectableLog() + log.write("error line") + log.start_search("") + assert log.search_query == "" + assert log.search_matches == [] + assert log.current_match_index == -1 + + def test_action_next_match_wraps_around(self) -> None: + """Test action_next_match navigation wraps from last to first.""" + log = SelectableLog() + log.write("error\nerror") + log.start_search("error") + assert log.current_match_index == 0 + log.action_next_match() + assert log.current_match_index == 1 + log.action_next_match() + assert log.current_match_index == 0 + + def test_action_previous_match_wraps_around(self) -> None: + """Test action_previous_match navigation wraps from first to last.""" + log = SelectableLog() + log.write("error\nerror") + log.start_search("error") + assert log.current_match_index == 0 + log.action_previous_match() + assert log.current_match_index == 1 + log.action_previous_match() + assert log.current_match_index == 0 + + def test_clear_search_resets_state(self) -> None: + """Test clear_search resets all search state.""" + log = SelectableLog() + log.write("error\nerror") + log.start_search("error") + log.clear_search() + assert log.search_query is None + assert log.search_matches == [] + assert log.current_match_index == 0 From b63396d25b265368e1da07ab20e253b46bb46fcf Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Mon, 22 Dec 2025 10:26:22 +0100 Subject: [PATCH 04/14] Add background color to SelectableLog widget --- campers/tui/styling.py | 3 +++ campers/tui/widgets/selectable_log.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/campers/tui/styling.py b/campers/tui/styling.py index 70f121b..d79203b 100644 --- a/campers/tui/styling.py +++ b/campers/tui/styling.py @@ -23,6 +23,9 @@ scrollbar-background-active: #383838; scrollbar-color-active: #606060; } +SelectableLog { + background: #1e1e1e; +} .hidden { display: none; } diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index ff6dcc5..20bd84a 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import ClassVar +from rich.segment import Segment from rich.style import Style from rich.text import Text from textual.geometry import Offset, Region, Size, Spacing @@ -170,7 +171,8 @@ def render_line(self, y: int) -> Strip: end_col = end[1] if line_index == end[0] else len(line.plain) line.stylize(self.SELECTION_STYLE, start_col, end_col) - return Strip(line.render(self.app.console)) + bg_style = Style(bgcolor="#1e1e1e") + return Strip([Segment(line.plain, bg_style)]) def _screen_to_content(self, offset: Offset) -> tuple[int, int]: """Convert screen coordinates to content coordinates. From 6bfc923375adbbb689cb31b2dd75ed9c200a6573 Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Mon, 22 Dec 2025 10:47:50 +0100 Subject: [PATCH 05/14] Preserve background color when rendering log lines --- campers/tui/widgets/selectable_log.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index 20bd84a..e78c1e9 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -171,8 +171,26 @@ def render_line(self, y: int) -> Strip: end_col = end[1] if line_index == end[0] else len(line.plain) line.stylize(self.SELECTION_STYLE, start_col, end_col) - bg_style = Style(bgcolor="#1e1e1e") - return Strip([Segment(line.plain, bg_style)]) + segments = list(line.render(self.app.console)) + bg_color = "#1e1e1e" + cleaned_segments = [] + + for seg in segments: + if seg.style: + new_style = Style( + color=seg.style.color, + bgcolor=seg.style.bgcolor or bg_color, + bold=seg.style.bold, + dim=seg.style.dim, + italic=seg.style.italic, + underline=seg.style.underline, + strike=seg.style.strike, + ) + else: + new_style = Style(bgcolor=bg_color) + cleaned_segments.append(Segment(seg.text, new_style, seg.control)) + + return Strip(cleaned_segments) def _screen_to_content(self, offset: Offset) -> tuple[int, int]: """Convert screen coordinates to content coordinates. From 977fce3a47b216778b7722e987f02181e9b8934f Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Mon, 22 Dec 2025 11:30:21 +0100 Subject: [PATCH 06/14] Update TUI styling and remove custom Ctrl+C handling - Add layers to Screen in TUI_CSS - Remove Ctrl+C binding and key handler from SelectableLog - Change context menu mouse button from 3 to 2 --- campers/tui/styling.py | 3 +++ campers/tui/widgets/selectable_log.py | 19 +------------------ 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/campers/tui/styling.py b/campers/tui/styling.py index d79203b..54061dc 100644 --- a/campers/tui/styling.py +++ b/campers/tui/styling.py @@ -1,6 +1,9 @@ """CSS styling for TUI application.""" TUI_CSS = """ +Screen { + layers: base overlay; +} #status-panel { height: auto; background: #383838; diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index e78c1e9..6af858c 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -70,7 +70,6 @@ class SelectableLog(ScrollView, can_focus=True): """ BINDINGS: ClassVar = [ - ("ctrl+c", "copy", "Copy"), ("ctrl+a", "select_all", "Select All"), ("slash", "open_search", "Search"), ("ctrl+f", "open_search", "Search"), @@ -221,7 +220,7 @@ def on_mouse_down(self, event) -> None: event Mouse event from Textual """ - if event.button == 3: + if event.button == 2: from campers.tui.widgets.context_menu import ContextMenu menu = self.app.query_one(ContextMenu) @@ -322,22 +321,6 @@ def action_copy(self) -> None: except Exception: self.app.notify("Clipboard unavailable", severity="warning") - def on_key(self, event) -> None: - """Handle key press events. - - Intercepts Ctrl+C when text is selected to prevent event bubbling. - Allows Ctrl+C to bubble when no text is selected (for quit handler). - - Parameters - ---------- - event - Key event from Textual - """ - if event.key == "ctrl+c" and not self.get_selected_text(): - return - if event.key == "ctrl+c": - self.action_copy() - event.stop() def action_select_all(self) -> None: """Select all text in the log. From 8a39cfcd4b45b5e411a450f3db9fcf0060707d3e Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Mon, 22 Dec 2025 11:32:25 +0100 Subject: [PATCH 07/14] Add "Copy" item to context menu in TUI --- campers/tui/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/campers/tui/app.py b/campers/tui/app.py index 8a4608c..c0abf50 100644 --- a/campers/tui/app.py +++ b/campers/tui/app.py @@ -137,7 +137,7 @@ def compose(self) -> ComposeResult: with Container(id="log-panel"): yield SelectableLog() yield SearchInput() - yield ContextMenu() + yield ContextMenu(items=["Copy"]) def on_mount(self) -> None: """Handle mount event - setup logging, start worker, and timer.""" From 95f050093b29a822875724340dbcaba635397f6b Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Mon, 22 Dec 2025 12:14:00 +0100 Subject: [PATCH 08/14] Pass target_widget in ContextMenu.ItemSelected message --- campers/tui/app.py | 3 +-- campers/tui/widgets/context_menu.py | 8 ++++++-- campers/tui/widgets/selectable_log.py | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/campers/tui/app.py b/campers/tui/app.py index c0abf50..4244503 100644 --- a/campers/tui/app.py +++ b/campers/tui/app.py @@ -191,8 +191,7 @@ async def on_context_menu_item_selected(self, message: ContextMenu.ItemSelected) message : ContextMenu.ItemSelected Message containing the selected action """ - menu = self.query_one(ContextMenu) - target = menu._target_widget + target = message.target_widget if message.action == "copy" and hasattr(target, "action_copy"): target.action_copy() diff --git a/campers/tui/widgets/context_menu.py b/campers/tui/widgets/context_menu.py index 54b01a7..802de11 100644 --- a/campers/tui/widgets/context_menu.py +++ b/campers/tui/widgets/context_menu.py @@ -82,10 +82,13 @@ class ItemSelected(Message): ---------- action : str Name of the selected action (lowercase item name) + target_widget + Reference to the widget that triggered the context menu """ - def __init__(self, action: str) -> None: + def __init__(self, action: str, target_widget) -> None: self.action = action + self.target_widget = target_widget super().__init__() def __init__(self, items: list[str] | None = None, **kwargs) -> None: @@ -240,7 +243,8 @@ def _activate_item(self, index: int) -> None: return action = item_name.lower() - self.post_message(self.ItemSelected(action)) + target = self._target_widget + self.post_message(self.ItemSelected(action, target)) self.hide() @property diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index 6af858c..03b48eb 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -306,6 +306,7 @@ def action_copy(self) -> None: OSC 52 clipboard method if pyperclip fails. """ text = self.get_selected_text() + if not text: return From 79444d9da07e465781601f406b96fb9877881ebb Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Mon, 22 Dec 2025 13:01:59 +0100 Subject: [PATCH 09/14] Replace status panel widgets with LabeledValue Add LabeledValue widget for aligned label/value display and value copying. Update CampersTUI to use LabeledValue for status panel fields. Refactor widget updates to set the value property. Update tests for new widget interface. --- campers/tui/app.py | 62 ++++++++------ campers/tui/widgets/__init__.py | 3 +- campers/tui/widgets/labeled_value.py | 116 ++++++++++++++++++++++++++ campers/tui/widgets/selectable_log.py | 2 +- tests/unit/test_app.py | 52 ++++++++---- tests/unit/test_main.py | 15 ++-- 6 files changed, 197 insertions(+), 53 deletions(-) create mode 100644 campers/tui/widgets/labeled_value.py diff --git a/campers/tui/app.py b/campers/tui/app.py index 4244503..50341f5 100644 --- a/campers/tui/app.py +++ b/campers/tui/app.py @@ -32,6 +32,7 @@ from campers.tui.styling import TUI_CSS from campers.tui.terminal import detect_terminal_background from campers.tui.widgets.context_menu import ContextMenu +from campers.tui.widgets.labeled_value import LabeledValue from campers.tui.widgets.search_input import SearchClosed, SearchInput, SearchQueryChanged from campers.tui.widgets.selectable_log import SelectableLog @@ -124,15 +125,15 @@ def compose(self) -> ComposeResult: """ with Container(id="status-panel"): yield InstanceOverviewWidget(self.campers) - yield Static("SSH: loading...", id=widgets.WidgetID.SSH) - yield Static("Status: launching...", id=widgets.WidgetID.STATUS) - yield Static("Uptime: 0s", id=widgets.WidgetID.UPTIME) - yield Static("Instance Type: loading...", id=widgets.WidgetID.INSTANCE_TYPE) - yield Static("Region: loading...", id=widgets.WidgetID.REGION) - yield Static("Camp Name: loading...", id=widgets.WidgetID.CAMP_NAME) - yield Static("Command: loading...", id=widgets.WidgetID.COMMAND) - yield Static("File sync: Not syncing", id=widgets.WidgetID.MUTAGEN) - yield Static("Port forwarding: none", id=widgets.WidgetID.PORTFORWARD) + yield LabeledValue("SSH", "loading...", id=widgets.WidgetID.SSH) + yield LabeledValue("Status", "launching...", id=widgets.WidgetID.STATUS) + yield LabeledValue("Uptime", "0s", id=widgets.WidgetID.UPTIME) + yield LabeledValue("Instance Type", "loading...", id=widgets.WidgetID.INSTANCE_TYPE) + yield LabeledValue("Region", "loading...", id=widgets.WidgetID.REGION) + yield LabeledValue("Camp Name", "loading...", id=widgets.WidgetID.CAMP_NAME) + yield LabeledValue("Command", "loading...", id=widgets.WidgetID.COMMAND) + yield LabeledValue("File sync", "Not syncing", id=widgets.WidgetID.MUTAGEN) + yield LabeledValue("Port forwarding", "none", id=widgets.WidgetID.PORTFORWARD) yield Static("", id=widgets.WidgetID.PUBLIC_PORTS, classes="hidden") with Container(id="log-panel"): yield SelectableLog() @@ -294,7 +295,7 @@ def update_uptime(self) -> None: uptime_str = f"{seconds}s" try: - self.query_one(f"#{widgets.WidgetID.UPTIME}").update(f"Uptime: {uptime_str}") + self.query_one(f"#{widgets.WidgetID.UPTIME}", LabeledValue).value = uptime_str except (ValueError, AttributeError) as e: logging.error("Failed to update uptime widget: %s", e) @@ -310,7 +311,7 @@ def update_status(self, payload: dict[str, Any]) -> None: status = payload["status"] try: - self.query_one(f"#{widgets.WidgetID.STATUS}").update(f"Status: {status}") + self.query_one(f"#{widgets.WidgetID.STATUS}", LabeledValue).value = status except (ValueError, AttributeError) as e: logging.error("Failed to update status widget: %s", e) @@ -325,23 +326,20 @@ def update_mutagen_status(self, payload: dict[str, Any]) -> None: status_text = payload.get("status_text") if status_text is not None: - if status_text == "idle": - display_text = "File sync: idle" - else: - display_text = f"File sync: {status_text}" + value = status_text else: state = payload.get("state", "unknown") files_synced = payload.get("files_synced") if state == "not_configured": - display_text = "File sync: Not syncing" + value = "Not syncing" elif files_synced is not None: - display_text = f"File sync: {state} ({files_synced} files)" + value = f"{state} ({files_synced} files)" else: - display_text = f"File sync: {state}" + value = state try: - self.query_one(f"#{widgets.WidgetID.MUTAGEN}").update(display_text) + self.query_one(f"#{widgets.WidgetID.MUTAGEN}", LabeledValue).value = value except (ValueError, AttributeError) as e: logging.error("Failed to update mutagen widget: %s", e) @@ -396,29 +394,39 @@ def update_from_config(self, config: dict[str, Any]) -> None: if "instance_type" in config: try: - self.query_one(f"#{widgets.WidgetID.INSTANCE_TYPE}").update( - f"Instance Type: {config['instance_type']}" + widget = self.query_one( + f"#{widgets.WidgetID.INSTANCE_TYPE}", LabeledValue ) + widget.value = config['instance_type'] except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update instance type widget: %s", e) if "region" in config: try: - self.query_one(f"#{widgets.WidgetID.REGION}").update(f"Region: {config['region']}") + widget = self.query_one( + f"#{widgets.WidgetID.REGION}", LabeledValue + ) + widget.value = config['region'] except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update region widget: %s", e) camp_name = config.get("camp_name", "ad-hoc") try: - self.query_one(f"#{widgets.WidgetID.CAMP_NAME}").update(f"Camp Name: {camp_name}") + widget = self.query_one( + f"#{widgets.WidgetID.CAMP_NAME}", LabeledValue + ) + widget.value = camp_name except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update camp name widget: %s", e) if "command" in config: try: cmd = config["command"] - self.query_one(f"#{widgets.WidgetID.COMMAND}").update(f"Command: {cmd}") + widget = self.query_one( + f"#{widgets.WidgetID.COMMAND}", LabeledValue + ) + widget.value = cmd except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update command widget: %s", e) @@ -451,7 +459,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None: """ if "state" in details: try: - self.query_one(f"#{widgets.WidgetID.STATUS}").update(f"Status: {details['state']}") + self.query_one(f"#{widgets.WidgetID.STATUS}", LabeledValue).value = details['state'] except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update status widget: %s", e) @@ -467,7 +475,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None: ssh_username = details.get("ssh_username", DEFAULT_SSH_USERNAME) key_file = details.get("key_file", "key.pem") ssh_string = f"ssh -o IdentitiesOnly=yes -i {key_file} {ssh_username}@{public_ip}" - self.query_one(f"#{widgets.WidgetID.SSH}").update(f"SSH: {ssh_string}") + self.query_one(f"#{widgets.WidgetID.SSH}", LabeledValue).value = ssh_string except (ValueError, AttributeError, RuntimeError) as e: logging.error("Failed to update SSH widget: %s", e) @@ -710,7 +718,7 @@ def handle_exit_choice(action: str | None) -> None: self._selected_exit_action = action try: - self.query_one(f"#{widgets.WidgetID.STATUS}").update("Status: shutting down") + self.query_one(f"#{widgets.WidgetID.STATUS}", LabeledValue).value = "shutting down" except (ValueError, AttributeError, RuntimeError) as e: logger.debug("Failed to update status widget during quit: %s", e) diff --git a/campers/tui/widgets/__init__.py b/campers/tui/widgets/__init__.py index c6f0299..f52f700 100644 --- a/campers/tui/widgets/__init__.py +++ b/campers/tui/widgets/__init__.py @@ -1,6 +1,7 @@ """TUI widgets module for campers.""" from campers.tui.widgets.context_menu import ContextMenu +from campers.tui.widgets.labeled_value import LabeledValue from campers.tui.widgets.search_input import SearchInput from campers.tui.widgets.selectable_log import SelectableLog from campers.tui.widgets.selection import Selection @@ -21,4 +22,4 @@ class WidgetID: PUBLIC_PORTS = "public-ports-widget" -__all__ = ["ContextMenu", "SearchInput", "SelectableLog", "Selection", "WidgetID"] +__all__ = ["ContextMenu", "LabeledValue", "SearchInput", "SelectableLog", "Selection", "WidgetID"] diff --git a/campers/tui/widgets/labeled_value.py b/campers/tui/widgets/labeled_value.py new file mode 100644 index 0000000..9b6978d --- /dev/null +++ b/campers/tui/widgets/labeled_value.py @@ -0,0 +1,116 @@ +"""Labeled value widget with aligned display and copyable value.""" + +from __future__ import annotations + +from textual.widgets import Static + +LABEL_WIDTH = 18 + + +class LabeledValue(Static): + """A widget that displays a label and value with alignment, supporting value copy. + + The label is left-aligned with fixed width, and the value follows. + Right-click copies just the value (not the label). + + Parameters + ---------- + label : str + The label text (e.g., "Status") + value : str + The value text (e.g., "running") + **kwargs + Additional keyword arguments passed to Static + """ + + def __init__(self, label: str, value: str = "", **kwargs) -> None: + """Initialize LabeledValue widget. + + Parameters + ---------- + label : str + The label text + value : str + The value text + **kwargs + Additional keyword arguments passed to Static + """ + self._label = label + self._value = value + super().__init__(self._format_display(), **kwargs) + + def _format_display(self) -> str: + """Format the label and value for display. + + Returns + ------- + str + Formatted string with aligned label and value + """ + label_with_colon = f"{self._label}:" + return f"{label_with_colon:<{LABEL_WIDTH}}{self._value}" + + @property + def value(self) -> str: + """Get the current value. + + Returns + ------- + str + The current value text + """ + return self._value + + @value.setter + def value(self, new_value: str) -> None: + """Set the value and update display. + + Parameters + ---------- + new_value : str + The new value text + """ + self._value = new_value + self.update(self._format_display()) + + def on_mouse_down(self, event) -> None: + """Handle mouse down event for context menu. + + Parameters + ---------- + event + Mouse event from Textual + """ + if event.button == 2: + from campers.tui.widgets.context_menu import ContextMenu + + menu = self.app.query_one(ContextMenu) + menu.show_at(event.screen_x, event.screen_y, self) + event.stop() + + def action_copy(self) -> None: + """Copy the value to clipboard (not the label).""" + if not self._value: + return + + try: + import pyperclip + + pyperclip.copy(self._value) + self.app.notify("Copied to clipboard") + except Exception: + try: + self.app.copy_to_clipboard(self._value) + self.app.notify("Copied to clipboard") + except Exception: + self.app.notify("Clipboard unavailable", severity="warning") + + def get_selected_text(self) -> str: + """Get the text for copying (returns just the value). + + Returns + ------- + str + The value text (not the label) + """ + return self._value diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index 03b48eb..13cbaf4 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -79,7 +79,7 @@ class SelectableLog(ScrollView, can_focus=True): ("shift+f3", "previous_match", "Previous Match"), ] - SELECTION_STYLE: ClassVar = Style(bgcolor="blue", color="white") + SELECTION_STYLE: ClassVar = Style(bgcolor="#3465a4", color="white") MATCH_STYLE: ClassVar = Style(bgcolor="yellow", color="black") CURRENT_MATCH_STYLE: ClassVar = Style(bgcolor="#ff8c00", color="black", bold=True) diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index cd62433..a20617c 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -1,6 +1,6 @@ """Unit tests for CampersTUI.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch import pytest @@ -30,13 +30,15 @@ def test_update_mutagen_status_with_status_text_idle(tui_app): tui_app : CampersTUI TUI app instance """ - mock_widget = Mock() + from campers.tui.widgets.labeled_value import LabeledValue + + mock_widget = Mock(spec=LabeledValue) tui_app.query_one = Mock(return_value=mock_widget) tui_app.update_mutagen_status({"status_text": "idle"}) - tui_app.query_one.assert_called_once_with("#mutagen-widget") - mock_widget.update.assert_called_once_with("File sync: idle") + tui_app.query_one.assert_called_once_with("#mutagen-widget", LabeledValue) + assert mock_widget.value == "idle" def test_update_mutagen_status_with_status_text_message(tui_app): @@ -47,13 +49,15 @@ def test_update_mutagen_status_with_status_text_message(tui_app): tui_app : CampersTUI TUI app instance """ - mock_widget = Mock() + from campers.tui.widgets.labeled_value import LabeledValue + + mock_widget = Mock(spec=LabeledValue) tui_app.query_one = Mock(return_value=mock_widget) status_msg = "Syncing: 42 files" tui_app.update_mutagen_status({"status_text": status_msg}) - mock_widget.update.assert_called_once_with(f"File sync: {status_msg}") + assert mock_widget.value == status_msg def test_update_mutagen_status_legacy_not_configured(tui_app): @@ -64,12 +68,14 @@ def test_update_mutagen_status_legacy_not_configured(tui_app): tui_app : CampersTUI TUI app instance """ - mock_widget = Mock() + from campers.tui.widgets.labeled_value import LabeledValue + + mock_widget = Mock(spec=LabeledValue) tui_app.query_one = Mock(return_value=mock_widget) tui_app.update_mutagen_status({"state": "not_configured"}) - mock_widget.update.assert_called_once_with("File sync: Not syncing") + assert mock_widget.value == "Not syncing" def test_update_mutagen_status_legacy_with_files_synced(tui_app): @@ -80,12 +86,14 @@ def test_update_mutagen_status_legacy_with_files_synced(tui_app): tui_app : CampersTUI TUI app instance """ - mock_widget = Mock() + from campers.tui.widgets.labeled_value import LabeledValue + + mock_widget = Mock(spec=LabeledValue) tui_app.query_one = Mock(return_value=mock_widget) tui_app.update_mutagen_status({"state": "synced", "files_synced": 42}) - mock_widget.update.assert_called_once_with("File sync: synced (42 files)") + assert mock_widget.value == "synced (42 files)" def test_update_mutagen_status_legacy_without_files_synced(tui_app): @@ -96,12 +104,14 @@ def test_update_mutagen_status_legacy_without_files_synced(tui_app): tui_app : CampersTUI TUI app instance """ - mock_widget = Mock() + from campers.tui.widgets.labeled_value import LabeledValue + + mock_widget = Mock(spec=LabeledValue) tui_app.query_one = Mock(return_value=mock_widget) tui_app.update_mutagen_status({"state": "syncing"}) - mock_widget.update.assert_called_once_with("File sync: syncing") + assert mock_widget.value == "syncing" def test_update_mutagen_status_legacy_unknown_state(tui_app): @@ -112,12 +122,14 @@ def test_update_mutagen_status_legacy_unknown_state(tui_app): tui_app : CampersTUI TUI app instance """ - mock_widget = Mock() + from campers.tui.widgets.labeled_value import LabeledValue + + mock_widget = Mock(spec=LabeledValue) tui_app.query_one = Mock(return_value=mock_widget) tui_app.update_mutagen_status({}) - mock_widget.update.assert_called_once_with("File sync: unknown") + assert mock_widget.value == "unknown" def test_update_mutagen_status_widget_not_found(tui_app): @@ -136,14 +148,17 @@ def test_update_mutagen_status_widget_not_found(tui_app): def test_update_mutagen_status_attribute_error(tui_app): - """Test error handling when widget has no update method. + """Test error handling when setting value raises AttributeError. Parameters ---------- tui_app : CampersTUI TUI app instance """ - mock_widget = Mock(spec=[]) + from campers.tui.widgets.labeled_value import LabeledValue + + mock_widget = Mock(spec=LabeledValue) + type(mock_widget).value = PropertyMock(side_effect=AttributeError("value property error")) tui_app.query_one = Mock(return_value=mock_widget) with patch("campers.tui.app.logging") as mock_logging: @@ -161,6 +176,7 @@ def tui_app_for_public_ports(): Mock CampersTUI instance for testing """ from campers.tui.app import CampersTUI + from campers.tui.widgets.labeled_value import LabeledValue app = Mock(spec=CampersTUI) app.update_from_config = CampersTUI.update_from_config.__get__(app) @@ -234,7 +250,7 @@ def test_public_ports_widget_not_shown_without_public_ip(tui_app_for_public_port public_ports_widget = Mock() other_widget = Mock() - def query_one_side_effect(selector): + def query_one_side_effect(selector, *args): if "public-ports" in selector: return public_ports_widget return other_widget @@ -280,7 +296,7 @@ def test_public_ports_widget_shown_on_instance_details_with_public_ip( public_ports_widget = Mock() other_widget = Mock() - def query_one_side_effect(selector): + def query_one_side_effect(selector, *args): if "public-ports" in selector: return public_ports_widget return other_widget diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index aac9877..4c86d98 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -717,14 +717,15 @@ def test_update_uptime_clamps_negative_values_to_zero(self, campers_tui): campers_tui : CampersTUI CampersTUI instance """ + from campers.tui.widgets.labeled_value import LabeledValue + future_time = datetime.now(UTC) + timedelta(hours=1) campers_tui.instance_start_time = future_time.replace(tzinfo=None) campers_tui.update_uptime() - queried_widget = campers_tui.query_one("#uptime-widget") - call_args = queried_widget.update.call_args - assert "0s" in str(call_args) + queried_widget = campers_tui.query_one("#uptime-widget", LabeledValue) + assert "0s" in queried_widget.value def test_update_uptime_formats_correctly_with_hours(self, campers_tui): """Verify uptime formats correctly as HH:MM:SS with 1+ hours. @@ -734,14 +735,16 @@ def test_update_uptime_formats_correctly_with_hours(self, campers_tui): campers_tui : CampersTUI CampersTUI instance """ + from campers.tui.widgets.labeled_value import LabeledValue + past_time = datetime.now(UTC) - timedelta(hours=3, minutes=45, seconds=30) campers_tui.instance_start_time = past_time.replace(tzinfo=None) campers_tui.update_uptime() - queried_widget = campers_tui.query_one("#uptime-widget") - call_args = queried_widget.update.call_args - assert "03:" in str(call_args) and "45:" in str(call_args) + queried_widget = campers_tui.query_one("#uptime-widget", LabeledValue) + value = queried_widget.value + assert "03:" in value and "45:" in value def test_update_uptime_handles_none_instance_start_time(self, campers_tui): """Verify update_uptime returns early when instance_start_time is None. From c869c053d69abc4f5fa656366312e14fc7515175 Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Tue, 23 Dec 2025 12:49:54 +0100 Subject: [PATCH 10/14] Add text selection support to LabeledValue widget --- campers/tui/styling.py | 9 ++ campers/tui/widgets/context_menu.py | 10 +++ campers/tui/widgets/labeled_value.py | 119 +++++++++++++++++++++++--- campers/tui/widgets/selectable_log.py | 9 ++ 4 files changed, 137 insertions(+), 10 deletions(-) diff --git a/campers/tui/styling.py b/campers/tui/styling.py index 54061dc..d870179 100644 --- a/campers/tui/styling.py +++ b/campers/tui/styling.py @@ -29,6 +29,15 @@ SelectableLog { background: #1e1e1e; } +LabeledValue { + background: transparent; +} +LabeledValue:hover { + background: transparent; +} +LabeledValue:focus { + background: transparent; +} .hidden { display: none; } diff --git a/campers/tui/widgets/context_menu.py b/campers/tui/widgets/context_menu.py index 802de11..63d943e 100644 --- a/campers/tui/widgets/context_menu.py +++ b/campers/tui/widgets/context_menu.py @@ -227,6 +227,16 @@ def on_click(self, event) -> None: self._activate_item(i) return + def on_blur(self, event) -> None: + """Handle blur event to close menu when clicking outside. + + Parameters + ---------- + event + Blur event from Textual + """ + self.hide() + def _activate_item(self, index: int) -> None: """Activate a menu item by index. diff --git a/campers/tui/widgets/labeled_value.py b/campers/tui/widgets/labeled_value.py index 9b6978d..758b7fe 100644 --- a/campers/tui/widgets/labeled_value.py +++ b/campers/tui/widgets/labeled_value.py @@ -2,6 +2,10 @@ from __future__ import annotations +from typing import ClassVar + +from rich.style import Style +from rich.text import Text from textual.widgets import Static LABEL_WIDTH = 18 @@ -11,7 +15,7 @@ class LabeledValue(Static): """A widget that displays a label and value with alignment, supporting value copy. The label is left-aligned with fixed width, and the value follows. - Right-click copies just the value (not the label). + Supports text selection via mouse drag, similar to SelectableLog. Parameters ---------- @@ -23,6 +27,8 @@ class LabeledValue(Static): Additional keyword arguments passed to Static """ + SELECTION_STYLE: ClassVar = Style(bgcolor="#3465a4", color="white") + def __init__(self, label: str, value: str = "", **kwargs) -> None: """Initialize LabeledValue widget. @@ -37,18 +43,29 @@ def __init__(self, label: str, value: str = "", **kwargs) -> None: """ self._label = label self._value = value + self._selection_start: int | None = None + self._selection_end: int | None = None + self._selecting = False super().__init__(self._format_display(), **kwargs) - def _format_display(self) -> str: + def _format_display(self) -> Text: """Format the label and value for display. Returns ------- - str - Formatted string with aligned label and value + Text + Rich Text with aligned label and value, with selection styling if selected """ label_with_colon = f"{self._label}:" - return f"{label_with_colon:<{LABEL_WIDTH}}{self._value}" + formatted = f"{label_with_colon:<{LABEL_WIDTH}}{self._value}" + text = Text(formatted) + + if self._selection_start is not None and self._selection_end is not None and self._value: + start = min(self._selection_start, self._selection_end) + end = max(self._selection_start, self._selection_end) + text.stylize(self.SELECTION_STYLE, start, end) + + return text @property def value(self) -> str: @@ -73,21 +90,90 @@ def value(self, new_value: str) -> None: self._value = new_value self.update(self._format_display()) + def _x_to_column(self, x: int) -> int: + """Convert x coordinate to column position in the formatted text. + + Parameters + ---------- + x : int + X coordinate of the click + + Returns + ------- + int + Column position, clamped to valid range + """ + col = max(0, x) + max_col = LABEL_WIDTH + len(self._value) + return min(col, max_col) + def on_mouse_down(self, event) -> None: - """Handle mouse down event for context menu. + """Handle mouse down event for selection and context menu. + + Left-click starts text selection via drag. + Right-click shows context menu. Parameters ---------- event Mouse event from Textual """ + if event.button == 1: + from campers.tui.widgets.context_menu import ContextMenu + + try: + menu = self.app.query_one(ContextMenu) + menu.hide() + except Exception: + pass + + for widget in self.app.query(LabeledValue): + if widget is not self: + widget.clear_selection() + + self.capture_mouse() + self._selecting = True + col = self._x_to_column(event.x) + self._selection_start = col + self._selection_end = col + self.update(self._format_display()) + event.stop() + return + if event.button == 2: from campers.tui.widgets.context_menu import ContextMenu menu = self.app.query_one(ContextMenu) - menu.show_at(event.screen_x, event.screen_y, self) + disabled = [] + if not self.get_selected_text(): + disabled.append("Copy") + menu.show_at(event.screen_x, event.screen_y, self, disabled_items=disabled) event.stop() + def on_mouse_move(self, event) -> None: + """Handle mouse move event to extend selection. + + Parameters + ---------- + event + Mouse event from Textual + """ + if not self._selecting: + return + self._selection_end = self._x_to_column(event.x) + self.update(self._format_display()) + + def on_mouse_up(self, event) -> None: + """Handle mouse up event to end selection. + + Parameters + ---------- + event + Mouse event from Textual + """ + self._selecting = False + self.release_mouse() + def action_copy(self) -> None: """Copy the value to clipboard (not the label).""" if not self._value: @@ -106,11 +192,24 @@ def action_copy(self) -> None: self.app.notify("Clipboard unavailable", severity="warning") def get_selected_text(self) -> str: - """Get the text for copying (returns just the value). + """Get the selected text portion. Returns ------- str - The value text (not the label) + The selected text, or empty string if no selection """ - return self._value + if self._selection_start is None or self._selection_end is None: + return "" + + start = min(self._selection_start, self._selection_end) + end = max(self._selection_start, self._selection_end) + full_text = f"{self._label}:{' ' * (LABEL_WIDTH - len(self._label) - 1)}{self._value}" + return full_text[start:end] + + def clear_selection(self) -> None: + """Clear the current selection and update display.""" + if self._selection_start is not None or self._selection_end is not None: + self._selection_start = None + self._selection_end = None + self.update(self._format_display()) diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index 13cbaf4..cb3d5ba 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -233,6 +233,15 @@ def on_mouse_down(self, event) -> None: if event.button != 1: return + + from campers.tui.widgets.context_menu import ContextMenu + + try: + menu = self.app.query_one(ContextMenu) + menu.hide() + except Exception: + pass + self.capture_mouse() self._selecting = True pos = self._screen_to_content(event.offset) From 648833e0804791357bc197fa5aa2deb8482bc284 Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Tue, 23 Dec 2025 13:21:19 +0100 Subject: [PATCH 11/14] Copy selected text to clipboard instead of full value --- campers/tui/widgets/labeled_value.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/campers/tui/widgets/labeled_value.py b/campers/tui/widgets/labeled_value.py index 758b7fe..d7f9b4c 100644 --- a/campers/tui/widgets/labeled_value.py +++ b/campers/tui/widgets/labeled_value.py @@ -175,18 +175,20 @@ def on_mouse_up(self, event) -> None: self.release_mouse() def action_copy(self) -> None: - """Copy the value to clipboard (not the label).""" - if not self._value: + """Copy the selected text to clipboard.""" + text = self.get_selected_text() + + if not text: return try: import pyperclip - pyperclip.copy(self._value) + pyperclip.copy(text) self.app.notify("Copied to clipboard") except Exception: try: - self.app.copy_to_clipboard(self._value) + self.app.copy_to_clipboard(text) self.app.notify("Copied to clipboard") except Exception: self.app.notify("Clipboard unavailable", severity="warning") From adc07d0d246b71dc19b44fd8c46ea0e3bd63fc92 Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Tue, 23 Dec 2025 14:07:06 +0100 Subject: [PATCH 12/14] Add copy binding to LabeledValue and SelectableLog widgets --- campers/tui/widgets/labeled_value.py | 6 +++++- campers/tui/widgets/selectable_log.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/campers/tui/widgets/labeled_value.py b/campers/tui/widgets/labeled_value.py index d7f9b4c..836b052 100644 --- a/campers/tui/widgets/labeled_value.py +++ b/campers/tui/widgets/labeled_value.py @@ -11,7 +11,7 @@ LABEL_WIDTH = 18 -class LabeledValue(Static): +class LabeledValue(Static, can_focus=True): """A widget that displays a label and value with alignment, supporting value copy. The label is left-aligned with fixed width, and the value follows. @@ -27,6 +27,10 @@ class LabeledValue(Static): Additional keyword arguments passed to Static """ + BINDINGS: ClassVar = [ + ("cmd+c", "copy", "Copy"), + ] + SELECTION_STYLE: ClassVar = Style(bgcolor="#3465a4", color="white") def __init__(self, label: str, value: str = "", **kwargs) -> None: diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index cb3d5ba..eff6f31 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -70,6 +70,7 @@ class SelectableLog(ScrollView, can_focus=True): """ BINDINGS: ClassVar = [ + ("cmd+c", "copy", "Copy"), ("ctrl+a", "select_all", "Select All"), ("slash", "open_search", "Search"), ("ctrl+f", "open_search", "Search"), From 44a630a857d77f49923203196fddc1084f2c97a3 Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Tue, 23 Dec 2025 16:11:10 +0100 Subject: [PATCH 13/14] Add Ctrl+C binding for copy action in widgets --- campers/tui/widgets/labeled_value.py | 1 + campers/tui/widgets/selectable_log.py | 1 + 2 files changed, 2 insertions(+) diff --git a/campers/tui/widgets/labeled_value.py b/campers/tui/widgets/labeled_value.py index 836b052..1d1d989 100644 --- a/campers/tui/widgets/labeled_value.py +++ b/campers/tui/widgets/labeled_value.py @@ -28,6 +28,7 @@ class LabeledValue(Static, can_focus=True): """ BINDINGS: ClassVar = [ + ("ctrl+c", "copy", "Copy"), ("cmd+c", "copy", "Copy"), ] diff --git a/campers/tui/widgets/selectable_log.py b/campers/tui/widgets/selectable_log.py index eff6f31..3e07784 100644 --- a/campers/tui/widgets/selectable_log.py +++ b/campers/tui/widgets/selectable_log.py @@ -70,6 +70,7 @@ class SelectableLog(ScrollView, can_focus=True): """ BINDINGS: ClassVar = [ + ("ctrl+c", "copy", "Copy"), ("cmd+c", "copy", "Copy"), ("ctrl+a", "select_all", "Select All"), ("slash", "open_search", "Search"), From cff4cfd4b2840fb75f2066bc258d973e85a31055 Mon Sep 17 00:00:00 2001 From: Kamil Ciemniewski Date: Sat, 27 Dec 2025 13:08:16 +0100 Subject: [PATCH 14/14] Bump version to 0.1.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 001ff29..12aa530 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "campers" -version = "0.1.4" +version = "0.1.5" description = "CLI tool for managing remote development environments on the cloud" readme = "README.md" license = {text = "MIT"}