rosight 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.
rosight/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Rosight — a lazygit-inspired terminal UI for ROS 2."""
2
+
3
+ from rosight.version import __version__
4
+
5
+ __all__ = ["__version__"]
rosight/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m rosight`` to launch the TUI."""
2
+
3
+ from rosight.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
rosight/app.py ADDED
@@ -0,0 +1,277 @@
1
+ """Top-level Textual application.
2
+
3
+ Holds the :class:`RosBackend`, the panel registry, and the global key
4
+ bindings. Each panel is implemented as a self-contained widget under
5
+ ``rosight.widgets`` and registered here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Vertical
15
+ from textual.reactive import reactive
16
+ from textual.widgets import Footer, Header, TabbedContent, TabPane
17
+
18
+ from rosight.config import Config, load_user_state, save_user_state
19
+ from rosight.ros.backend import RosBackend, RosUnavailable
20
+ from rosight.widgets.actions_panel import ActionsPanel
21
+ from rosight.widgets.bags_panel import BagsPanel
22
+ from rosight.widgets.command_palette import CommandPalette
23
+ from rosight.widgets.help_screen import HelpScreen
24
+ from rosight.widgets.interfaces_panel import InterfacesPanel
25
+ from rosight.widgets.nodes_panel import NodesPanel
26
+ from rosight.widgets.params_panel import ParamsPanel
27
+ from rosight.widgets.plot_panel import PlotPanel
28
+ from rosight.widgets.services_panel import ServicesPanel
29
+ from rosight.widgets.status_bar import StatusBar
30
+ from rosight.widgets.tf_panel import TfPanel
31
+ from rosight.widgets.topics_panel import TopicsPanel
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ class RosightApp(App[int]):
37
+ """Main Textual application."""
38
+
39
+ CSS_PATH = "app.tcss"
40
+ TITLE = "Rosight"
41
+ SUB_TITLE = "ROS 2 TUI"
42
+
43
+ BINDINGS = [
44
+ Binding("q,ctrl+c", "quit", "Quit"),
45
+ Binding("?", "help", "Help"),
46
+ Binding(":", "command", "Command"),
47
+ Binding("r", "refresh", "Refresh"),
48
+ Binding("1", "tab('topics')", "Messages", show=False),
49
+ Binding("2", "tab('nodes')", "Nodes", show=False),
50
+ Binding("3", "tab('services')", "Services", show=False),
51
+ Binding("4", "tab('actions')", "Actions", show=False),
52
+ Binding("5", "tab('params')", "Params", show=False),
53
+ Binding("6", "tab('plot')", "Plot", show=False),
54
+ Binding("7", "tab('tf')", "TF", show=False),
55
+ Binding("8", "tab('bags')", "Bags", show=False),
56
+ Binding("9", "tab('interfaces')", "Interfaces", show=False),
57
+ ]
58
+
59
+ backend_ok: reactive[bool] = reactive(False)
60
+
61
+ def __init__(
62
+ self,
63
+ config: Config | None = None,
64
+ *,
65
+ ros: RosBackend | None = None,
66
+ ) -> None:
67
+ super().__init__()
68
+ self.config: Config = config or Config()
69
+ self._owns_ros = ros is None
70
+ self.ros: RosBackend = ros or RosBackend(
71
+ domain_id=self.config.ros.domain_id,
72
+ default_depth=self.config.ros.queue_depth,
73
+ )
74
+
75
+ # --------------- compose / lifecycle ---------------
76
+
77
+ def compose(self) -> ComposeResult:
78
+ yield Header(show_clock=True)
79
+ with TabbedContent(initial="topics", id="main-tabs"):
80
+ with TabPane("Messages", id="topics"):
81
+ yield TopicsPanel()
82
+ with TabPane("Nodes", id="nodes"):
83
+ yield NodesPanel()
84
+ with TabPane("Services", id="services"):
85
+ yield ServicesPanel()
86
+ with TabPane("Actions", id="actions"):
87
+ yield ActionsPanel()
88
+ with TabPane("Params", id="params"):
89
+ yield ParamsPanel()
90
+ with TabPane("Plot", id="plot"):
91
+ yield PlotPanel()
92
+ with TabPane("TF", id="tf"):
93
+ yield TfPanel()
94
+ with TabPane("Bags", id="bags"):
95
+ yield BagsPanel()
96
+ with TabPane("Interfaces", id="interfaces"):
97
+ yield InterfacesPanel()
98
+ with Vertical(id="bottom-bar"):
99
+ yield StatusBar(id="status")
100
+ yield Footer()
101
+
102
+ async def on_mount(self) -> None:
103
+ # Restore the theme the user last picked via Ctrl+P → Change theme.
104
+ # Failures are silent: if the state file is missing or names a theme
105
+ # Textual no longer ships, we just stay on the default.
106
+ try:
107
+ state = load_user_state()
108
+ saved = state.get("theme")
109
+ if isinstance(saved, str) and self.get_theme(saved) is not None:
110
+ self.theme = saved
111
+ except Exception:
112
+ log.debug("could not restore theme", exc_info=True)
113
+
114
+ # Persist on every subsequent theme change.
115
+ try:
116
+ self.theme_changed_signal.subscribe(self, self._on_theme_changed)
117
+ except Exception:
118
+ log.debug("could not subscribe to theme_changed_signal", exc_info=True)
119
+
120
+ # Try to start the ROS backend; downgrade to a banner on failure.
121
+ if self._owns_ros:
122
+ try:
123
+ self.ros.start()
124
+ self.backend_ok = True
125
+ except RosUnavailable as e:
126
+ self.backend_ok = False
127
+ self.notify(str(e), title="ROS 2 unavailable", severity="warning", timeout=10)
128
+ except Exception:
129
+ log.exception("ROS backend failed to start")
130
+ self.backend_ok = False
131
+ self.notify(
132
+ "ROS backend failed to start — see logs.",
133
+ title="error",
134
+ severity="error",
135
+ )
136
+ else:
137
+ self.backend_ok = self.ros.started
138
+
139
+ self.set_interval(self.config.ui.discovery_period, self._refresh_status)
140
+
141
+ def _on_theme_changed(self, theme) -> None:
142
+ """Persist the chosen theme so it survives restarts."""
143
+ try:
144
+ name = getattr(theme, "name", None) or self.theme
145
+ if not isinstance(name, str):
146
+ return
147
+ state = load_user_state()
148
+ state["theme"] = name
149
+ save_user_state(state)
150
+ except Exception:
151
+ log.debug("could not persist theme", exc_info=True)
152
+
153
+ async def on_unmount(self) -> None:
154
+ if self._owns_ros:
155
+ try:
156
+ self.ros.stop()
157
+ except Exception:
158
+ log.exception("ros stop failed")
159
+
160
+ # --------------- actions ---------------
161
+
162
+ def action_help(self) -> None:
163
+ self.push_screen(HelpScreen())
164
+
165
+ def action_command(self) -> None:
166
+ self.push_screen(CommandPalette(), self._on_command_submitted)
167
+
168
+ def action_refresh(self) -> None:
169
+ self.push_status("refresh requested")
170
+ # Each panel polls on a timer; nothing else needed.
171
+
172
+ def action_tab(self, tab_id: str) -> None:
173
+ try:
174
+ self.query_one(TabbedContent).active = tab_id
175
+ except Exception:
176
+ pass
177
+
178
+ # --------------- helpers exposed to panels ---------------
179
+
180
+ def push_status(self, msg: str) -> None:
181
+ try:
182
+ bar = self.query_one("#status", StatusBar)
183
+ bar.message = msg
184
+ except Exception:
185
+ log.info(msg)
186
+ log.info("status: %s", msg)
187
+
188
+ def add_plot_series(self, topic: str, field_path: str) -> None:
189
+ """Forward field-selected events from the topics panel to plot."""
190
+ try:
191
+ panel = self.query_one(PlotPanel)
192
+ panel.add_series(topic, field_path)
193
+ self.action_tab("plot")
194
+ except Exception:
195
+ log.exception("could not add plot series")
196
+
197
+ # --------------- background tickers ---------------
198
+
199
+ def _refresh_status(self) -> None:
200
+ bar = self.query_one("#status", StatusBar)
201
+ bar.backend_ok = self.backend_ok and self.ros.started
202
+ bar.domain_id = self.ros.domain_id
203
+ if not self.ros.started:
204
+ return
205
+ try:
206
+ bar.topics = len(self.ros.list_topics())
207
+ bar.nodes = len(self.ros.list_nodes())
208
+ bar.services = len(self.ros.list_services())
209
+ bar.actions = len(self.ros.list_actions())
210
+ bar.subs = len(self.ros.active_subscriptions())
211
+ except Exception:
212
+ log.debug("status refresh failed", exc_info=True)
213
+
214
+ # --------------- command palette ---------------
215
+
216
+ def _on_command_submitted(self, raw: str | None) -> None:
217
+ if not raw:
218
+ return
219
+ parts = raw.strip().split()
220
+ cmd, args = parts[0], parts[1:]
221
+ if cmd in ("q", "quit", "exit"):
222
+ self.exit(0)
223
+ elif cmd == "topic" and args:
224
+ self.action_tab("topics")
225
+ try:
226
+ tp = self.query_one(TopicsPanel)
227
+ tp.filter_text = args[0]
228
+ except Exception:
229
+ pass
230
+ elif cmd == "node" and args:
231
+ self.action_tab("nodes")
232
+ elif cmd == "param" and len(args) >= 1:
233
+ self.action_tab("params")
234
+ elif cmd == "plot" and len(args) >= 2:
235
+ topic, path = args[0], args[1]
236
+ self.add_plot_series(topic, path)
237
+ elif cmd == "record":
238
+ self.action_tab("bags")
239
+ elif cmd == "help":
240
+ self.action_help()
241
+ elif cmd == "domain" and args:
242
+ self._switch_domain(args[0])
243
+ else:
244
+ self.push_status(f"unknown command: {raw}")
245
+
246
+ def _switch_domain(self, raw_id: str) -> None:
247
+ """Re-init the rclpy backend on a new ROS_DOMAIN_ID.
248
+
249
+ Tears down the executor + node + context and reconnects on the
250
+ target domain. Any subscriptions are lost — panels rediscover and
251
+ the user can re-subscribe.
252
+ """
253
+ try:
254
+ new_id = int(raw_id)
255
+ except ValueError:
256
+ self.notify(f"invalid domain id: {raw_id!r}", severity="warning", title="Domain")
257
+ return
258
+ if not 0 <= new_id <= 232: # ROS_DOMAIN_ID range
259
+ self.notify(
260
+ f"domain id {new_id} outside valid range 0..232",
261
+ severity="warning",
262
+ title="Domain",
263
+ )
264
+ return
265
+ try:
266
+ self.ros.set_domain_id(new_id)
267
+ self.backend_ok = self.ros.started
268
+ self.notify(
269
+ f"now on ROS_DOMAIN_ID={new_id}\n(subscriptions cleared)",
270
+ title="Domain",
271
+ )
272
+ except RosUnavailable as e:
273
+ self.backend_ok = False
274
+ self.notify(str(e), title="ROS 2 unavailable", severity="warning")
275
+ except Exception as e:
276
+ log.exception("set_domain_id failed")
277
+ self.notify(f"failed to switch domain: {e}", severity="error")
rosight/app.tcss ADDED
@@ -0,0 +1,70 @@
1
+ /* rosight — Textual stylesheet */
2
+
3
+ Screen {
4
+ background: $background;
5
+ color: $text;
6
+ }
7
+
8
+ Header {
9
+ dock: top;
10
+ height: 1;
11
+ background: $primary 30%;
12
+ color: $text;
13
+ }
14
+
15
+ #bottom-bar {
16
+ dock: bottom;
17
+ height: 2;
18
+ layout: vertical;
19
+ }
20
+
21
+ Footer {
22
+ dock: initial;
23
+ height: 1;
24
+ background: $primary 20%;
25
+ color: $text;
26
+ }
27
+
28
+ TabbedContent {
29
+ height: 1fr;
30
+ }
31
+
32
+ TabPane {
33
+ padding: 0;
34
+ height: 1fr;
35
+ }
36
+
37
+ DataTable > .datatable--header {
38
+ background: $primary 40%;
39
+ color: $text;
40
+ text-style: bold;
41
+ }
42
+
43
+ DataTable > .datatable--cursor {
44
+ background: $accent 35%;
45
+ }
46
+
47
+ Tree {
48
+ background: $surface;
49
+ }
50
+
51
+ Input {
52
+ background: $surface;
53
+ border: tall $primary 30%;
54
+ }
55
+
56
+ Input:focus {
57
+ border: tall $accent;
58
+ }
59
+
60
+ Static.dim {
61
+ color: $text-muted;
62
+ }
63
+
64
+ #detail-header {
65
+ text-style: bold;
66
+ }
67
+
68
+ ToastRack > Toast {
69
+ background: $primary 70%;
70
+ }
rosight/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """Command-line entry point for ``rosight``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from rosight.config import Config, config_path, load_config
11
+ from rosight.version import __version__
12
+
13
+
14
+ def _setup_logging(level: str, log_file: Path | None) -> None:
15
+ handlers: list[logging.Handler] = []
16
+ if log_file is not None:
17
+ log_file.parent.mkdir(parents=True, exist_ok=True)
18
+ handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
19
+ logging.basicConfig(
20
+ level=level.upper(),
21
+ format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
22
+ handlers=handlers or [logging.NullHandler()],
23
+ )
24
+
25
+
26
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
27
+ p = argparse.ArgumentParser(
28
+ prog="rosight",
29
+ description="lazygit-style terminal UI for ROS 2",
30
+ )
31
+ p.add_argument(
32
+ "-c",
33
+ "--config",
34
+ type=Path,
35
+ help=f"path to config TOML (default: {config_path()})",
36
+ )
37
+ p.add_argument("--domain-id", type=int, help="override ROS_DOMAIN_ID")
38
+ p.add_argument("--node-name", default="rosight", help="rclpy node name")
39
+ p.add_argument(
40
+ "--log-level",
41
+ default="INFO",
42
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
43
+ )
44
+ p.add_argument(
45
+ "--log-file",
46
+ type=Path,
47
+ help="path to a log file (default: stderr suppressed)",
48
+ )
49
+ p.add_argument(
50
+ "--no-ros",
51
+ action="store_true",
52
+ help="skip ROS 2 initialisation (useful for UI-only debugging)",
53
+ )
54
+ p.add_argument("-V", "--version", action="version", version=f"rosight {__version__}")
55
+ return p.parse_args(argv)
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ args = parse_args(argv)
60
+ _setup_logging(args.log_level, args.log_file)
61
+
62
+ try:
63
+ cfg: Config = load_config(args.config)
64
+ except ValueError as e:
65
+ print(f"config error: {e}", file=sys.stderr)
66
+ return 2
67
+
68
+ if args.domain_id is not None:
69
+ from dataclasses import replace
70
+
71
+ cfg = replace(cfg, ros=replace(cfg.ros, domain_id=args.domain_id))
72
+
73
+ # Defer Textual import to keep --version fast.
74
+ from rosight.app import RosightApp
75
+ from rosight.ros.backend import RosBackend
76
+
77
+ ros = None
78
+ if args.no_ros:
79
+ ros = RosBackend(node_name=args.node_name, domain_id=cfg.ros.domain_id)
80
+ # leave it un-started; the app will downgrade gracefully.
81
+
82
+ app = RosightApp(config=cfg, ros=ros)
83
+ return app.run() or 0
84
+
85
+
86
+ if __name__ == "__main__":
87
+ raise SystemExit(main())
rosight/config.py ADDED
@@ -0,0 +1,153 @@
1
+ """User configuration loading.
2
+
3
+ Configuration lives at ``$XDG_CONFIG_HOME/rosight/config.toml`` (falling back
4
+ to ``~/.config/rosight/config.toml``). Missing files are not an error — the
5
+ defaults are applied automatically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from dataclasses import dataclass, field, replace
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from platformdirs import user_config_dir
17
+
18
+ if sys.version_info >= (3, 11):
19
+ import tomllib
20
+ else: # pragma: no cover
21
+ import tomli as tomllib # type: ignore[no-redef]
22
+
23
+
24
+ CONFIG_FILENAME = "config.toml"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class PlotConfig:
29
+ window_seconds: float = 30.0
30
+ max_points: int = 5_000
31
+ refresh_hz: float = 15.0
32
+ show_legend: bool = True
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class StatsConfig:
37
+ window_seconds: float = 5.0
38
+ max_samples: int = 4096
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class UIConfig:
43
+ theme: str = "rosight-dark"
44
+ refresh_hz: float = 10.0
45
+ discovery_period: float = 1.0 # how often to refresh topic/node lists
46
+ vim_keys: bool = True
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class RosConfig:
51
+ domain_id: int | None = None # None -> inherit ROS_DOMAIN_ID
52
+ qos_profile: str = "auto" # auto | sensor_data | reliable | best_effort
53
+ queue_depth: int = 10
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Config:
58
+ ui: UIConfig = field(default_factory=UIConfig)
59
+ plot: PlotConfig = field(default_factory=PlotConfig)
60
+ stats: StatsConfig = field(default_factory=StatsConfig)
61
+ ros: RosConfig = field(default_factory=RosConfig)
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict[str, Any]) -> Config:
65
+ """Build a Config from a parsed TOML dict, ignoring unknown keys."""
66
+ return cls(
67
+ ui=_merge(UIConfig(), data.get("ui", {})),
68
+ plot=_merge(PlotConfig(), data.get("plot", {})),
69
+ stats=_merge(StatsConfig(), data.get("stats", {})),
70
+ ros=_merge(RosConfig(), data.get("ros", {})),
71
+ )
72
+
73
+
74
+ def _merge(default: Any, overrides: dict[str, Any]) -> Any:
75
+ """Replace dataclass fields with values from a mapping; ignore unknowns."""
76
+ if not overrides:
77
+ return default
78
+ valid = {f.name for f in default.__dataclass_fields__.values()} # type: ignore[attr-defined]
79
+ filtered = {k: v for k, v in overrides.items() if k in valid}
80
+ return replace(default, **filtered)
81
+
82
+
83
+ def config_path() -> Path:
84
+ """Return the resolved config file path (does not require it to exist)."""
85
+ base = os.environ.get("ROSIGHT_CONFIG")
86
+ if base:
87
+ return Path(base).expanduser()
88
+ return Path(user_config_dir("rosight")) / CONFIG_FILENAME
89
+
90
+
91
+ def load_config(path: Path | None = None) -> Config:
92
+ """Load configuration from ``path`` (or the default location).
93
+
94
+ Returns defaults if the file does not exist. Raises ``ValueError`` only
95
+ if the file exists but is malformed.
96
+ """
97
+ p = path or config_path()
98
+ if not p.exists():
99
+ return Config()
100
+ try:
101
+ with p.open("rb") as f:
102
+ data = tomllib.load(f)
103
+ except tomllib.TOMLDecodeError as e:
104
+ raise ValueError(f"Invalid TOML in {p}: {e}") from e
105
+ return Config.from_dict(data)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Runtime user state (theme choice, ...). Kept separate from ``config.toml``
110
+ # so the user's hand-written config is never rewritten by the app.
111
+ # ---------------------------------------------------------------------------
112
+
113
+ STATE_FILENAME = "state.toml"
114
+
115
+
116
+ def state_path() -> Path:
117
+ return Path(user_config_dir("rosight")) / STATE_FILENAME
118
+
119
+
120
+ def load_user_state() -> dict[str, Any]:
121
+ """Return the persisted user-state dict (theme, etc.). Empty on miss."""
122
+ p = state_path()
123
+ if not p.exists():
124
+ return {}
125
+ try:
126
+ with p.open("rb") as f:
127
+ return tomllib.load(f)
128
+ except Exception:
129
+ return {}
130
+
131
+
132
+ def save_user_state(state: dict[str, Any]) -> None:
133
+ """Atomically replace the state file with ``state``.
134
+
135
+ Best-effort: failure to write (read-only FS, etc.) is silently swallowed
136
+ so we never crash the app over a cosmetic preference.
137
+ """
138
+ p = state_path()
139
+ try:
140
+ p.parent.mkdir(parents=True, exist_ok=True)
141
+ tmp = p.with_suffix(p.suffix + ".tmp")
142
+ with tmp.open("w", encoding="utf-8") as f:
143
+ for k, v in state.items():
144
+ if isinstance(v, str):
145
+ escaped = v.replace("\\", "\\\\").replace('"', '\\"')
146
+ f.write(f'{k} = "{escaped}"\n')
147
+ elif isinstance(v, bool):
148
+ f.write(f"{k} = {'true' if v else 'false'}\n")
149
+ elif isinstance(v, (int, float)):
150
+ f.write(f"{k} = {v}\n")
151
+ tmp.replace(p)
152
+ except Exception:
153
+ pass
@@ -0,0 +1,17 @@
1
+ """ROS 2 backend abstractions used by the TUI.
2
+
3
+ Importing this package does **not** import ``rclpy``. The backend lazily
4
+ imports rclpy inside :class:`RosBackend.start` so the rest of the package
5
+ (and the test suite) can run without a ROS 2 installation.
6
+ """
7
+
8
+ from rosight.ros.backend import RosBackend, RosUnavailable, ros_available
9
+ from rosight.ros.stats import BandwidthMonitor, RateMonitor
10
+
11
+ __all__ = [
12
+ "BandwidthMonitor",
13
+ "RateMonitor",
14
+ "RosBackend",
15
+ "RosUnavailable",
16
+ "ros_available",
17
+ ]