ros2-term 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ros2_term/__init__.py +0 -0
- ros2_term/app.css +76 -0
- ros2_term/app.py +558 -0
- ros2_term/lib/__init__.py +0 -0
- ros2_term/lib/config.py +90 -0
- ros2_term/lib/entity.py +259 -0
- ros2_term/lib/history.py +62 -0
- ros2_term/lib/messages.py +43 -0
- ros2_term/lib/ros2_client.py +438 -0
- ros2_term/lib/topic_watcher.py +281 -0
- ros2_term/ui/__init__.py +0 -0
- ros2_term/ui/help_screen.py +161 -0
- ros2_term/ui/profile_screen.py +86 -0
- ros2_term/ui/screens.py +93 -0
- ros2_term/ui/search_bar.py +140 -0
- ros2_term/ui/widgets.py +1602 -0
- ros2_term-0.1.0.dist-info/METADATA +113 -0
- ros2_term-0.1.0.dist-info/RECORD +20 -0
- ros2_term-0.1.0.dist-info/WHEEL +4 -0
- ros2_term-0.1.0.dist-info/entry_points.txt +2 -0
ros2_term/__init__.py
ADDED
|
File without changes
|
ros2_term/app.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
layout: vertical;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/* Multi-screen layout */
|
|
6
|
+
.container {
|
|
7
|
+
height: 100%;
|
|
8
|
+
background: $panel;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
#left-panel-container {
|
|
12
|
+
width: 50%;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
NodeTreePanel, TopicTreePanel, RosEntityListPanel {
|
|
16
|
+
padding-left: 2;
|
|
17
|
+
width: 100%;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
RosEntityInfoPanel, RosTypeDefinitionPanel {
|
|
21
|
+
padding: 1 2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#main {
|
|
25
|
+
width: 70%;
|
|
26
|
+
border-left: inner $primary;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#main-upper {
|
|
30
|
+
border-bottom: inner $primary;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.main-half {
|
|
34
|
+
height: 50%;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------- */
|
|
38
|
+
SearchBar {
|
|
39
|
+
max-width: 100%;
|
|
40
|
+
height: 5;
|
|
41
|
+
padding: 0 1;
|
|
42
|
+
border: solid green;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
SearchBar Input {
|
|
46
|
+
width: 1fr;
|
|
47
|
+
min-width: 10;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
SearchBar .search-button {
|
|
51
|
+
width: 4;
|
|
52
|
+
margin-left: 1;
|
|
53
|
+
min-width: 4;
|
|
54
|
+
text-style: bold;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
SearchBar .search-button.hover {
|
|
58
|
+
background: $surface;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
SearchBar .search-button.active {
|
|
62
|
+
background: $secondary;
|
|
63
|
+
text-style: bold;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------------------------- */
|
|
67
|
+
|
|
68
|
+
#close-button {
|
|
69
|
+
width: 30;
|
|
70
|
+
margin: 1;
|
|
71
|
+
color: white;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#button-container {
|
|
75
|
+
align-horizontal: center;
|
|
76
|
+
}
|
ros2_term/app.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
|
9
|
+
from textual.widgets import Footer, Static
|
|
10
|
+
|
|
11
|
+
from ros2_term.lib.config import ProfileConfig
|
|
12
|
+
from ros2_term.lib.entity import RosEntity, RosEntityType
|
|
13
|
+
from ros2_term.lib.history import History
|
|
14
|
+
from ros2_term.lib.messages import RosEntitySelected
|
|
15
|
+
from ros2_term.lib.ros2_client import ROS2Client
|
|
16
|
+
from ros2_term.lib.topic_watcher import TopicWatcher
|
|
17
|
+
|
|
18
|
+
from ros2_term.ui.search_bar import SearchBar, SearchChanged
|
|
19
|
+
from ros2_term.ui.help_screen import HelpScreen
|
|
20
|
+
from ros2_term.ui.profile_screen import ProfileSaveScreen
|
|
21
|
+
from ros2_term.ui.widgets import (
|
|
22
|
+
RosEntityInfoPanel,
|
|
23
|
+
RosEntityListPanel,
|
|
24
|
+
RosTypeDefinitionPanel,
|
|
25
|
+
NodeTreePanel,
|
|
26
|
+
TopicTreePanel,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
warnings.simplefilter("ignore", ResourceWarning)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ROS2Explorer(App):
|
|
33
|
+
"""TUI app for exploring ROS2 nodes, topics, services, actions, and types."""
|
|
34
|
+
|
|
35
|
+
TITLE = "ROS2 Explorer"
|
|
36
|
+
CSS_PATH = "app.css"
|
|
37
|
+
|
|
38
|
+
DEFAULT_REFRESH_RATE = 2.0
|
|
39
|
+
|
|
40
|
+
BINDINGS = [
|
|
41
|
+
# Show in footer
|
|
42
|
+
Binding("?", "help", "Help", key_display="?"),
|
|
43
|
+
Binding("w", "watch_topic", "Watch", key_display="w"),
|
|
44
|
+
Binding("h", "show_hz", "Hz", key_display="h"),
|
|
45
|
+
Binding("e", "echo_topic", "Echo", key_display="e"),
|
|
46
|
+
Binding("s", "sample_topic", "Sample", key_display="s"),
|
|
47
|
+
Binding("a", "toggle_arrays", "Toggle Arrays", key_display="a"),
|
|
48
|
+
Binding("r", "reload", "Reload", key_display="r"),
|
|
49
|
+
# Hide from footer (still functional, documented in help)
|
|
50
|
+
Binding("b", "back", "Back", key_display="b", show=False),
|
|
51
|
+
Binding("f", "forward", "Forward", key_display="f", show=False),
|
|
52
|
+
Binding("v", "toggle_verbose", "Verbose", key_display="v", show=False),
|
|
53
|
+
Binding("p", "save_profile", "Save Profile", key_display="p", show=False),
|
|
54
|
+
Binding("c", "collapse_all", "Collapse All", key_display="c", show=False),
|
|
55
|
+
Binding("1", "show_nodes", "Nodes", key_display="1", show=False),
|
|
56
|
+
Binding("2", "show_topics", "Topics", key_display="2", show=False),
|
|
57
|
+
Binding("3", "show_services", "Services", key_display="3", show=False),
|
|
58
|
+
Binding("4", "show_actions", "Actions", key_display="4", show=False),
|
|
59
|
+
Binding("5", "show_msg_types", "Msg Types", key_display="5", show=False),
|
|
60
|
+
Binding("q", "quit", "Quit", key_display="q", show=False),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def __init__(self, profile_name: str | None = None, refresh_rate: float = None) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self._ros_client: ROS2Client | None = None
|
|
66
|
+
self._topic_watcher: TopicWatcher | None = None
|
|
67
|
+
self._profile_config = ProfileConfig()
|
|
68
|
+
self._current_profile: str | None = profile_name
|
|
69
|
+
self._watch_list: set[str] = set() # Topics that should be watched when discovered
|
|
70
|
+
self._refresh_rate = refresh_rate or self.DEFAULT_REFRESH_RATE
|
|
71
|
+
self._history: History[RosEntity] = History(20)
|
|
72
|
+
self._current_entity_type = RosEntityType.Node
|
|
73
|
+
self._left_panel_container: Vertical | None = None
|
|
74
|
+
self._current_tree_panel: Static | None = None
|
|
75
|
+
self._info_panel: RosEntityInfoPanel | None = None
|
|
76
|
+
self._definition_panel: RosTypeDefinitionPanel | None = None
|
|
77
|
+
self._verbose_mode: bool = False
|
|
78
|
+
self._current_topic: str | None = None # Track selected topic for hz/echo
|
|
79
|
+
# Store tree expansion state per panel type
|
|
80
|
+
self._tree_states: dict = {
|
|
81
|
+
RosEntityType.Node: {"expanded": set(), "selected": None},
|
|
82
|
+
RosEntityType.Topic: {"expanded": set(), "selected": None},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def compose(self) -> ComposeResult:
|
|
86
|
+
"""Compose the app layout."""
|
|
87
|
+
yield SearchBar(id="search-bar")
|
|
88
|
+
with Horizontal(classes="container"):
|
|
89
|
+
# Left panel container (will be swapped based on view)
|
|
90
|
+
with Vertical(id="left-panel-container") as container:
|
|
91
|
+
self._left_panel_container = container
|
|
92
|
+
# Will be populated in on_mount
|
|
93
|
+
|
|
94
|
+
# Info/definition panels on the right
|
|
95
|
+
with Vertical(id="main"):
|
|
96
|
+
with ScrollableContainer(
|
|
97
|
+
id="main-upper", classes="main-half"
|
|
98
|
+
) as info_container:
|
|
99
|
+
pass # Will be populated in on_mount
|
|
100
|
+
with ScrollableContainer(
|
|
101
|
+
id="def-container", classes="main-half"
|
|
102
|
+
) as def_container:
|
|
103
|
+
pass # Will be populated in on_mount
|
|
104
|
+
yield Footer()
|
|
105
|
+
|
|
106
|
+
def on_mount(self) -> None:
|
|
107
|
+
"""Initialize ROS2 client and create initial widgets."""
|
|
108
|
+
try:
|
|
109
|
+
self._ros_client = ROS2Client()
|
|
110
|
+
self._topic_watcher = TopicWatcher(self._ros_client)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
self.exit(message=f"Failed to initialize ROS2 client: {e}")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Load profile immediately to get view and watch list
|
|
116
|
+
if self._current_profile:
|
|
117
|
+
self._load_profile(self._current_profile)
|
|
118
|
+
|
|
119
|
+
# Create and mount initial tree panel based on current entity type
|
|
120
|
+
if self._current_entity_type == RosEntityType.Node:
|
|
121
|
+
self._current_tree_panel = NodeTreePanel(
|
|
122
|
+
self._ros_client, self._topic_watcher, refresh_rate=self._refresh_rate
|
|
123
|
+
)
|
|
124
|
+
elif self._current_entity_type == RosEntityType.Topic:
|
|
125
|
+
self._current_tree_panel = TopicTreePanel(
|
|
126
|
+
self._ros_client, self._topic_watcher, refresh_rate=self._refresh_rate
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
self._current_tree_panel = RosEntityListPanel(
|
|
130
|
+
self._ros_client, self._current_entity_type, refresh_rate=self._refresh_rate
|
|
131
|
+
)
|
|
132
|
+
self._left_panel_container.mount(self._current_tree_panel)
|
|
133
|
+
|
|
134
|
+
# Mount info panel
|
|
135
|
+
self._info_panel = RosEntityInfoPanel(
|
|
136
|
+
self._ros_client, None, update_interval=5.0
|
|
137
|
+
)
|
|
138
|
+
self.query_one("#main-upper").mount(self._info_panel)
|
|
139
|
+
|
|
140
|
+
# Mount definition panel
|
|
141
|
+
self._definition_panel = RosTypeDefinitionPanel(
|
|
142
|
+
self._ros_client, self._topic_watcher
|
|
143
|
+
)
|
|
144
|
+
self.query_one("#def-container").mount(self._definition_panel)
|
|
145
|
+
|
|
146
|
+
# Set up interval to refresh propeller animation
|
|
147
|
+
self.set_interval(0.1, self._refresh_propellers)
|
|
148
|
+
|
|
149
|
+
# Set initial focus on the tree panel without auto-scrolling
|
|
150
|
+
self._current_tree_panel.focus(scroll_visible=False)
|
|
151
|
+
|
|
152
|
+
def should_watch_topic(self, topic_name: str) -> bool:
|
|
153
|
+
"""Check if a topic should be watched (is in watch list)."""
|
|
154
|
+
return topic_name in self._watch_list
|
|
155
|
+
|
|
156
|
+
def watch_discovered_topic(self, topic_name: str) -> None:
|
|
157
|
+
"""Watch a topic that was just discovered (called from tree panels)."""
|
|
158
|
+
if topic_name in self._watch_list and not self._topic_watcher.is_watched(topic_name):
|
|
159
|
+
try:
|
|
160
|
+
self._topic_watcher.watch_topic(topic_name)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass # Silently fail, will retry on next discovery
|
|
163
|
+
|
|
164
|
+
def _save_tree_state(self) -> None:
|
|
165
|
+
"""Save current tree panel's expanded state."""
|
|
166
|
+
if not self._current_tree_panel or self._current_entity_type not in self._tree_states:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
if hasattr(self._current_tree_panel, 'root'):
|
|
170
|
+
expanded = set()
|
|
171
|
+
for node in self._current_tree_panel.root.children:
|
|
172
|
+
if node.is_expanded and node.data:
|
|
173
|
+
expanded.add(node.data.get("name"))
|
|
174
|
+
self._tree_states[self._current_entity_type]["expanded"] = expanded
|
|
175
|
+
|
|
176
|
+
# Save selected node path
|
|
177
|
+
if self._current_tree_panel.cursor_node and self._current_tree_panel.cursor_node.data:
|
|
178
|
+
# Build path from root to selected node
|
|
179
|
+
path = []
|
|
180
|
+
current = self._current_tree_panel.cursor_node
|
|
181
|
+
root = self._current_tree_panel.root
|
|
182
|
+
while current and current != root:
|
|
183
|
+
path.insert(0, {
|
|
184
|
+
"type": current.data.get("type") if current.data else None,
|
|
185
|
+
"name": current.data.get("name") if current.data else None,
|
|
186
|
+
"label": str(current.label)
|
|
187
|
+
})
|
|
188
|
+
current = current.parent
|
|
189
|
+
|
|
190
|
+
self._tree_states[self._current_entity_type]["selected"] = {
|
|
191
|
+
"path": path
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def _restore_tree_state(self) -> None:
|
|
195
|
+
"""Restore tree panel's expanded state after it's mounted."""
|
|
196
|
+
if not self._current_tree_panel or self._current_entity_type not in self._tree_states:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if hasattr(self._current_tree_panel, 'set_initial_state'):
|
|
200
|
+
state = self._tree_states[self._current_entity_type]
|
|
201
|
+
self._current_tree_panel.set_initial_state(
|
|
202
|
+
expanded=state.get("expanded", set()),
|
|
203
|
+
selected=state.get("selected")
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _switch_entity_type(self, entity_type: RosEntityType) -> None:
|
|
207
|
+
"""Switch to viewing a different entity type."""
|
|
208
|
+
# Save state of current panel before switching
|
|
209
|
+
self._save_tree_state()
|
|
210
|
+
|
|
211
|
+
self._current_entity_type = entity_type
|
|
212
|
+
|
|
213
|
+
# Remove old tree panel
|
|
214
|
+
if self._current_tree_panel:
|
|
215
|
+
self._current_tree_panel.remove()
|
|
216
|
+
|
|
217
|
+
# Create appropriate new panel based on entity type
|
|
218
|
+
if entity_type == RosEntityType.Node:
|
|
219
|
+
self._current_tree_panel = NodeTreePanel(
|
|
220
|
+
self._ros_client, self._topic_watcher, refresh_rate=self._refresh_rate
|
|
221
|
+
)
|
|
222
|
+
elif entity_type == RosEntityType.Topic:
|
|
223
|
+
self._current_tree_panel = TopicTreePanel(
|
|
224
|
+
self._ros_client, self._topic_watcher, refresh_rate=self._refresh_rate
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
# For services, actions, and types, use flat list
|
|
228
|
+
self._current_tree_panel = RosEntityListPanel(
|
|
229
|
+
self._ros_client, entity_type, refresh_rate=self._refresh_rate
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Mount the new panel
|
|
233
|
+
self._left_panel_container.mount(self._current_tree_panel)
|
|
234
|
+
|
|
235
|
+
# Restore saved state
|
|
236
|
+
self._restore_tree_state()
|
|
237
|
+
|
|
238
|
+
# Clear info panel
|
|
239
|
+
if self._info_panel:
|
|
240
|
+
self._info_panel._entity = None
|
|
241
|
+
self._info_panel.update("")
|
|
242
|
+
if self._definition_panel:
|
|
243
|
+
if entity_type.has_definition():
|
|
244
|
+
self._definition_panel.display = True
|
|
245
|
+
else:
|
|
246
|
+
self._definition_panel.display = False
|
|
247
|
+
|
|
248
|
+
def show_ros_entity(self, entity: RosEntity, append_history: bool = True) -> None:
|
|
249
|
+
"""Navigate to a specific ROS entity - shows info without switching views."""
|
|
250
|
+
# Just show the entity info in the right panel, don't switch views
|
|
251
|
+
# This allows viewing topic details from node view, node details from topic view, etc.
|
|
252
|
+
|
|
253
|
+
# Track if this is a topic for hz/echo commands
|
|
254
|
+
if entity.type == RosEntityType.Topic:
|
|
255
|
+
self._current_topic = entity.name
|
|
256
|
+
|
|
257
|
+
# If topic is watched, automatically show hz stats
|
|
258
|
+
if self._topic_watcher and self._topic_watcher.is_watched(entity.name):
|
|
259
|
+
if self._definition_panel:
|
|
260
|
+
self._definition_panel.show_watched_topic_hz(entity.name)
|
|
261
|
+
self._definition_panel.display = True
|
|
262
|
+
else:
|
|
263
|
+
self._current_topic = None
|
|
264
|
+
|
|
265
|
+
if self._info_panel:
|
|
266
|
+
self._info_panel.set_entity(entity)
|
|
267
|
+
if self._definition_panel and entity.type.has_definition():
|
|
268
|
+
# Only set entity if it's not a watched topic (we already handled that above)
|
|
269
|
+
if not (entity.type == RosEntityType.Topic and
|
|
270
|
+
self._topic_watcher and self._topic_watcher.is_watched(entity.name)):
|
|
271
|
+
self._definition_panel.set_entity(entity)
|
|
272
|
+
self._definition_panel.display = True
|
|
273
|
+
else:
|
|
274
|
+
if self._definition_panel:
|
|
275
|
+
# Only hide if it's not a watched topic
|
|
276
|
+
if not (entity.type == RosEntityType.Topic and
|
|
277
|
+
self._topic_watcher and self._topic_watcher.is_watched(entity.name)):
|
|
278
|
+
self._definition_panel.display = False
|
|
279
|
+
|
|
280
|
+
if append_history:
|
|
281
|
+
self._history.append(entity)
|
|
282
|
+
|
|
283
|
+
def on_ros_entity_selected(self, event: RosEntitySelected) -> None:
|
|
284
|
+
"""Handle entity selection from clickable links or tree."""
|
|
285
|
+
self.show_ros_entity(event.entity)
|
|
286
|
+
|
|
287
|
+
def on_search_changed(self, event: SearchChanged) -> None:
|
|
288
|
+
"""Handle search criteria changes."""
|
|
289
|
+
search_bar = self.query_one("#search-bar", SearchBar)
|
|
290
|
+
|
|
291
|
+
# Set filter function on the current tree panel
|
|
292
|
+
if self._current_tree_panel and hasattr(self._current_tree_panel, "set_filter"):
|
|
293
|
+
self._current_tree_panel.set_filter(lambda name: search_bar.matches(name))
|
|
294
|
+
|
|
295
|
+
def action_back(self) -> None:
|
|
296
|
+
"""Navigate back in history."""
|
|
297
|
+
if entity := self._history.back():
|
|
298
|
+
self.show_ros_entity(entity, append_history=False)
|
|
299
|
+
|
|
300
|
+
def action_forward(self) -> None:
|
|
301
|
+
"""Navigate forward in history."""
|
|
302
|
+
if entity := self._history.forward():
|
|
303
|
+
self.show_ros_entity(entity, append_history=False)
|
|
304
|
+
|
|
305
|
+
def action_reload(self) -> None:
|
|
306
|
+
"""Reload the current entity list."""
|
|
307
|
+
if self._current_tree_panel and hasattr(
|
|
308
|
+
self._current_tree_panel, "update_items"
|
|
309
|
+
):
|
|
310
|
+
self._current_tree_panel.update_items()
|
|
311
|
+
|
|
312
|
+
def action_collapse_all(self) -> None:
|
|
313
|
+
"""Collapse all top-level tree nodes."""
|
|
314
|
+
if self._current_tree_panel and hasattr(self._current_tree_panel, 'root'):
|
|
315
|
+
for node in self._current_tree_panel.root.children:
|
|
316
|
+
node.collapse()
|
|
317
|
+
|
|
318
|
+
def action_toggle_verbose(self) -> None:
|
|
319
|
+
"""Toggle verbose mode to show/hide default ROS2 topics/services/actions."""
|
|
320
|
+
self._verbose_mode = not self._verbose_mode
|
|
321
|
+
|
|
322
|
+
# Update the tree panel's verbose mode
|
|
323
|
+
if self._current_tree_panel and hasattr(
|
|
324
|
+
self._current_tree_panel, "set_verbose_mode"
|
|
325
|
+
):
|
|
326
|
+
self._current_tree_panel.set_verbose_mode(self._verbose_mode)
|
|
327
|
+
|
|
328
|
+
# Update the info panel's verbose mode
|
|
329
|
+
if self._info_panel and hasattr(self._info_panel, "set_verbose_mode"):
|
|
330
|
+
self._info_panel.set_verbose_mode(self._verbose_mode)
|
|
331
|
+
|
|
332
|
+
# Notify user
|
|
333
|
+
mode_text = "ON" if self._verbose_mode else "OFF"
|
|
334
|
+
self.notify(f"Verbose mode: {mode_text}")
|
|
335
|
+
|
|
336
|
+
def action_show_nodes(self) -> None:
|
|
337
|
+
"""Show nodes view."""
|
|
338
|
+
self._switch_entity_type(RosEntityType.Node)
|
|
339
|
+
self._auto_save_profile()
|
|
340
|
+
|
|
341
|
+
def action_show_topics(self) -> None:
|
|
342
|
+
"""Show topics view."""
|
|
343
|
+
self._switch_entity_type(RosEntityType.Topic)
|
|
344
|
+
self._auto_save_profile()
|
|
345
|
+
|
|
346
|
+
def action_show_services(self) -> None:
|
|
347
|
+
"""Show services view."""
|
|
348
|
+
self._switch_entity_type(RosEntityType.Service)
|
|
349
|
+
self._auto_save_profile()
|
|
350
|
+
|
|
351
|
+
def action_show_actions(self) -> None:
|
|
352
|
+
"""Show actions view."""
|
|
353
|
+
self._switch_entity_type(RosEntityType.Action)
|
|
354
|
+
self._auto_save_profile()
|
|
355
|
+
|
|
356
|
+
def action_show_msg_types(self) -> None:
|
|
357
|
+
"""Show message types view."""
|
|
358
|
+
self._switch_entity_type(RosEntityType.MsgType)
|
|
359
|
+
self._auto_save_profile()
|
|
360
|
+
|
|
361
|
+
def action_show_hz(self) -> None:
|
|
362
|
+
"""Show Hz (publishing rate) for the selected topic."""
|
|
363
|
+
if not self._current_topic:
|
|
364
|
+
self.notify("Please select a topic first", severity="warning")
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
if self._definition_panel:
|
|
368
|
+
# Check if topic is watched
|
|
369
|
+
if self._topic_watcher and self._topic_watcher.is_watched(self._current_topic):
|
|
370
|
+
# Show watched topic hz (uses existing subscription)
|
|
371
|
+
self._definition_panel.show_watched_topic_hz(self._current_topic)
|
|
372
|
+
else:
|
|
373
|
+
# Show hz for unwatched topic (creates temp subscription)
|
|
374
|
+
self._definition_panel.show_hz(self._current_topic)
|
|
375
|
+
self._definition_panel.display = True
|
|
376
|
+
|
|
377
|
+
def action_echo_topic(self) -> None:
|
|
378
|
+
"""Start continuous echo of the selected topic."""
|
|
379
|
+
if not self._current_topic:
|
|
380
|
+
self.notify("Please select a topic first", severity="warning")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
if self._definition_panel:
|
|
384
|
+
self._definition_panel.start_echo(self._current_topic, continuous=True)
|
|
385
|
+
self._definition_panel.display = True
|
|
386
|
+
|
|
387
|
+
def action_sample_topic(self) -> None:
|
|
388
|
+
"""Sample a single message from the selected topic."""
|
|
389
|
+
if not self._current_topic:
|
|
390
|
+
self.notify("Please select a topic first", severity="warning")
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
if self._definition_panel:
|
|
394
|
+
self._definition_panel.start_echo(self._current_topic, continuous=False)
|
|
395
|
+
self._definition_panel.display = True
|
|
396
|
+
|
|
397
|
+
def action_toggle_arrays(self) -> None:
|
|
398
|
+
"""Toggle array visibility in echo/sample output."""
|
|
399
|
+
if self._definition_panel:
|
|
400
|
+
self._definition_panel.toggle_arrays()
|
|
401
|
+
|
|
402
|
+
def action_watch_topic(self) -> None:
|
|
403
|
+
"""Toggle watching the selected topic."""
|
|
404
|
+
if not self._current_topic:
|
|
405
|
+
self.notify("Please select a topic first", severity="warning")
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
if not self._topic_watcher:
|
|
409
|
+
self.notify("Topic watcher not initialized", severity="error")
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
is_watched = self._topic_watcher.toggle_watch(self._current_topic)
|
|
414
|
+
if is_watched:
|
|
415
|
+
# Add to watch list
|
|
416
|
+
self._watch_list.add(self._current_topic)
|
|
417
|
+
# Automatically show hz display when watching
|
|
418
|
+
if self._definition_panel:
|
|
419
|
+
self._definition_panel.show_watched_topic_hz(self._current_topic)
|
|
420
|
+
self._definition_panel.display = True
|
|
421
|
+
else:
|
|
422
|
+
# Remove from watch list
|
|
423
|
+
self._watch_list.discard(self._current_topic)
|
|
424
|
+
# else: unwatched, no action needed (user sees propeller disappear)
|
|
425
|
+
|
|
426
|
+
# Auto-save to default profile
|
|
427
|
+
self._auto_save_profile()
|
|
428
|
+
except Exception as e:
|
|
429
|
+
self.notify(f"Failed to watch topic: {e}", severity="error")
|
|
430
|
+
|
|
431
|
+
def action_save_profile(self) -> None:
|
|
432
|
+
"""Save current watched topics to a profile (prompts for name)."""
|
|
433
|
+
if not self._topic_watcher:
|
|
434
|
+
self.notify("Topic watcher not initialized", severity="error")
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
# Show profile save screen to get name
|
|
438
|
+
self.push_screen(
|
|
439
|
+
ProfileSaveScreen(self._current_profile),
|
|
440
|
+
self._handle_profile_save
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def _handle_profile_save(self, profile_name: str | None) -> None:
|
|
444
|
+
"""Handle the profile save after user enters name."""
|
|
445
|
+
if not profile_name:
|
|
446
|
+
return # User cancelled
|
|
447
|
+
|
|
448
|
+
watched = self._topic_watcher.get_watched_topics()
|
|
449
|
+
current_view = self._current_entity_type.name
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
self._profile_config.save_profile(profile_name, watched, current_view)
|
|
453
|
+
self._current_profile = profile_name
|
|
454
|
+
self.notify(f"Saved profile '{profile_name}' ({len(watched)} topics)")
|
|
455
|
+
except Exception as e:
|
|
456
|
+
self.notify(f"Failed to save profile: {e}", severity="error")
|
|
457
|
+
|
|
458
|
+
def _auto_save_profile(self) -> None:
|
|
459
|
+
"""Auto-save to default profile silently."""
|
|
460
|
+
if not self._topic_watcher:
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
watched = self._topic_watcher.get_watched_topics()
|
|
464
|
+
current_view = self._current_entity_type.name
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
self._profile_config.save_profile("default", watched, current_view)
|
|
468
|
+
except Exception:
|
|
469
|
+
pass # Silently fail on auto-save
|
|
470
|
+
|
|
471
|
+
def _load_profile(self, profile_name: str) -> None:
|
|
472
|
+
"""Load profile - sets view and watch list."""
|
|
473
|
+
try:
|
|
474
|
+
data = self._profile_config.load_profile(profile_name)
|
|
475
|
+
if not data:
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
current_view = data.get("current_view", "Node")
|
|
479
|
+
watched_topics = data.get("watched_topics", [])
|
|
480
|
+
|
|
481
|
+
# Restore the view
|
|
482
|
+
try:
|
|
483
|
+
entity_type = RosEntityType[current_view]
|
|
484
|
+
self._current_entity_type = entity_type
|
|
485
|
+
except (KeyError, ValueError):
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
# Store watch list - topics will be watched as they're discovered
|
|
489
|
+
self._watch_list = set(watched_topics)
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
def _refresh_propellers(self) -> None:
|
|
494
|
+
"""Refresh the propeller display for watched topics."""
|
|
495
|
+
if self._current_tree_panel and hasattr(self._current_tree_panel, "refresh_propellers"):
|
|
496
|
+
self._current_tree_panel.refresh_propellers()
|
|
497
|
+
|
|
498
|
+
def action_help(self) -> None:
|
|
499
|
+
"""Show help screen."""
|
|
500
|
+
self.push_screen(HelpScreen())
|
|
501
|
+
|
|
502
|
+
async def action_quit(self) -> None:
|
|
503
|
+
"""Quit the application."""
|
|
504
|
+
if self._topic_watcher:
|
|
505
|
+
self._topic_watcher.cleanup()
|
|
506
|
+
if self._ros_client:
|
|
507
|
+
self._ros_client.terminate()
|
|
508
|
+
await super().action_quit()
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def main():
|
|
512
|
+
# Get available profiles for help text
|
|
513
|
+
config = ProfileConfig()
|
|
514
|
+
profiles = config.list_profiles()
|
|
515
|
+
profile_list = ", ".join(profiles) if profiles else "none"
|
|
516
|
+
|
|
517
|
+
epilog = f"Available profiles: {profile_list}\nProfile location: {config.config_dir}"
|
|
518
|
+
|
|
519
|
+
parser = argparse.ArgumentParser(
|
|
520
|
+
description="ROS2 Terminal UI for exploring nodes, topics, services, and actions",
|
|
521
|
+
epilog=epilog,
|
|
522
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
523
|
+
)
|
|
524
|
+
parser.add_argument(
|
|
525
|
+
"--profile",
|
|
526
|
+
type=str,
|
|
527
|
+
help="Profile name to load (without .json extension)",
|
|
528
|
+
)
|
|
529
|
+
parser.add_argument(
|
|
530
|
+
"--continue",
|
|
531
|
+
dest="continue_last",
|
|
532
|
+
action="store_true",
|
|
533
|
+
help="Continue from the last used profile",
|
|
534
|
+
)
|
|
535
|
+
parser.add_argument(
|
|
536
|
+
"--refresh-rate",
|
|
537
|
+
type=float,
|
|
538
|
+
default=2.0,
|
|
539
|
+
help="Refresh rate in seconds for updating topics/nodes list (default: 2.0)",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
args = parser.parse_args()
|
|
543
|
+
|
|
544
|
+
# Determine which profile to use
|
|
545
|
+
profile_name = None
|
|
546
|
+
if args.continue_last:
|
|
547
|
+
config = ProfileConfig()
|
|
548
|
+
profile_name = config.get_last_profile()
|
|
549
|
+
if not profile_name:
|
|
550
|
+
profile_name = "default"
|
|
551
|
+
elif args.profile:
|
|
552
|
+
profile_name = args.profile
|
|
553
|
+
|
|
554
|
+
app = ROS2Explorer(profile_name=profile_name, refresh_rate=args.refresh_rate)
|
|
555
|
+
app.run()
|
|
556
|
+
|
|
557
|
+
if __name__ == "__main__":
|
|
558
|
+
main()
|
|
File without changes
|