From 5b1ce750b362c9bd70f6f13fcfa5339a5e0c005b Mon Sep 17 00:00:00 2001 From: mlisi1 Date: Wed, 24 Jun 2026 18:40:50 +0200 Subject: [PATCH 1/4] colored ros2 topic list --- dendROS/dendROS.sh | 3 + dendROS/dendros_topic_list.py | 280 +++++++++++++++++++ install.sh | 2 + test/unit/test_topic_list.py | 513 ++++++++++++++++++++++++++++++++++ 4 files changed, 798 insertions(+) create mode 100644 dendROS/dendros_topic_list.py create mode 100644 test/unit/test_topic_list.py diff --git a/dendROS/dendROS.sh b/dendROS/dendROS.sh index 660d615..a9d2b57 100644 --- a/dendROS/dendROS.sh +++ b/dendROS/dendROS.sh @@ -83,6 +83,9 @@ ros2() { elif [[ "$1" == "param" && "$2" == "describe" ]]; then "$_ROS2_BIN" "$@" | python3 "${_DENDROS_DIR}/dendros_param_describe.py" "${@:3}" return ${PIPESTATUS[0]} + elif [[ "$1" == "topic" && "$2" == "list" ]]; then + "$_ROS2_BIN" "$@" | python3 "${_DENDROS_DIR}/dendros_topic_list.py" + return ${PIPESTATUS[0]} else "$_ROS2_BIN" "$@" fi diff --git a/dendROS/dendros_topic_list.py b/dendROS/dendros_topic_list.py new file mode 100644 index 0000000..eba877d --- /dev/null +++ b/dendROS/dendros_topic_list.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Colorize ros2 topic list with publisher color and aligned pub/sub count indicators.""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + import yaml +except ImportError: + yaml = None + +from lib.config_loader import load_config, merge_color_maps, resolve_node, resolve_node_style +from lib.colors import RESET, _resolve_color +from lib.global_config import load_global_config, get_node_colors_path +import lib.ros_graph as ros_graph + +_INDENT = ' ' + +# System topics shown plain (no color, tag, or count blocks) +_SYSTEM_TOPICS = {'/parameter_events', '/rosout'} + + +def _load_shared_colors(): + if yaml is None: + return {}, {}, {} + path = get_node_colors_path() + if not os.path.isfile(path): + return {}, {}, {} + try: + with open(path) as f: + data = yaml.safe_load(f) or {} + return ( + data.get('color_map', {}), + data.get('tag_map', {}), + data.get('style_map', {}), + ) + except Exception: + return {}, {}, {} + + +def _scan_configs(): + if yaml is None: + return [] + seen, paths = set(), [] + for prefix in os.environ.get('AMENT_PREFIX_PATH', '').split(':'): + share = os.path.join(prefix, 'share') if prefix else '' + if not share or not os.path.isdir(share): + continue + try: + for pkg in sorted(os.listdir(share)): + candidate = os.path.join(share, pkg, 'config', 'dendROS.yaml') + if os.path.isfile(candidate) and candidate not in seen: + seen.add(candidate) + paths.append(candidate) + except OSError: + pass + return paths + + +def _badge(label, ansi_code, style): + if style == 'inverted': + return f'\033[{ansi_code};7m[{label}]{RESET}' + return f'\033[{ansi_code}m[{label}]{RESET}' + + +def _nodes_to_groups(nodes, color_map, tag_map): + """[(code, count), ...] ordered by first encounter.""" + counts = {} + order = [] + for node in nodes: + code, _ = resolve_node(node, color_map, tag_map) + if code: + if code not in counts: + order.append(code) + counts[code] = counts.get(code, 0) + 1 + return [(c, counts[c]) for c in order] + + +def _count_blocks(groups): + return ' '.join(f'\033[{c};7m{n}\033[0m' for c, n in groups) + + +def _vis_pub_w(groups): + """Visual (plain-text) width of pub count blocks.""" + if not groups: + return 0 + return sum(len(str(n)) for _, n in groups) + max(0, len(groups) - 1) + + +def _vis_mid_w(badge_label, name, type_str): + """Visual width of the middle column: '[LABEL] name [type]'. + Pass badge_label=None when no badge is shown.""" + w = len(name) + if type_str: + w += len(type_str) + 3 # ' [' + type + ']' + if badge_label: + w += len(badge_label) + 3 + 1 # '[LBL] ' + return w + + +def _split_type(line): + if line.endswith(']') and ' [' in line: + name, rest = line.rsplit(' [', 1) + return name, rest[:-1] + return line, None + + +def _fetch_from_env(item_set, env_key): + ov = os.environ.get(env_key) + if ov is None: + return None + try: + injected = json.loads(ov) + except (json.JSONDecodeError, ValueError): + injected = {} + return {item: injected.get(item, []) for item in item_set} + + +def main(): + cfg = load_global_config() + show_tag = cfg['show_tag_cli'] + tag_style = cfg['tag_style'] + unmatched_clr = cfg['unmatched_color'] + unmatched_tag = cfg['unmatched_tag'] + dim_unmatched = cfg['dim_unmatched'] + unmatched_ansi = _resolve_color(unmatched_clr) if unmatched_clr else None + + color_map, tag_map, style_map = _load_shared_colors() + if not color_map: + config_paths = _scan_configs() + tuples = [] + for path in config_paths: + try: + c, t, m, s, k, _ = load_config(path) + tuples.append((c, t, m, s, k)) + except Exception: + pass + if tuples: + c0, t0, m0, s0, k0 = tuples[0] + color_map, tag_map, _, style_map, _ = merge_color_maps( + c0, t0, m0, s0, k0, tuples[1:] + ) + + # ── Parse input ─────────────────────────────────────────────────────────── + raw_lines = [line.rstrip('\n') for line in sys.stdin] + all_topics = set() # excludes system topics + parsed = [] # [(raw, name_or_None, type_str_or_None)] + + for raw in raw_lines: + if not raw: + parsed.append((raw, None, None)) + continue + name, type_str = _split_type(raw) + if name not in _SYSTEM_TOPICS: + all_topics.add(name) + parsed.append((raw, name, type_str)) + + # ── Graph query (system topics excluded) ────────────────────────────────── + pub_ov = _fetch_from_env(all_topics, 'DENDROS_TOPIC_PUBLISHERS') + sub_ov = _fetch_from_env(all_topics, 'DENDROS_TOPIC_SUBSCRIBERS') + pub_nodes = pub_ov or {} + sub_nodes = sub_ov or {} + + if color_map and all_topics and (pub_ov is None or sub_ov is None): + live_pub = all_topics if pub_ov is None else set() + live_sub = all_topics if sub_ov is None else set() + try: + graph = ros_graph.get_all_providers(topics=live_pub, pub_topics=live_sub) + if pub_ov is None: + pub_nodes = {t: graph.get(t, []) for t in live_pub} + if sub_ov is None: + sub_nodes = {t: graph.get(ros_graph._PUB_SUB_PREFIX + t, []) + for t in live_sub} + except Exception: + pass + + pub_groups = {t: _nodes_to_groups(pub_nodes.get(t, []), color_map, tag_map) + for t in all_topics} + sub_groups = {t: _nodes_to_groups(sub_nodes.get(t, []), color_map, tag_map) + for t in all_topics} + + # ── Pass 1: resolve rendering case + visual widths ──────────────────────── + # Tuple: (case, ansi, badge_label, node_style, pgroups, sgroups, name, type_str) + render_info = [] + pub_vws = [] # per non-system topic + mid_vws = [] # per non-system topic + + for raw, name, type_str in parsed: + if not name: + render_info.append(('empty', None, None, None, [], [], None, None)) + continue + + if name in _SYSTEM_TOPICS: + render_info.append(('system', None, None, None, [], [], name, type_str)) + continue + + pgroups = pub_groups.get(name, []) + sgroups = sub_groups.get(name, []) + pub_list = pub_nodes.get(name, []) + primary = pub_list[0] if pub_list else None + + if primary: + ansi, badge_label = resolve_node(primary, color_map, tag_map) + ns = resolve_node_style(primary, style_map) or tag_style + else: + ansi, badge_label, ns = None, None, tag_style + + if ansi: + case = 'matched' + disp_badge = badge_label if (show_tag and badge_label) else None + elif unmatched_ansi: + case = 'unmatched' + ansi = unmatched_ansi + badge_label = unmatched_tag + ns = tag_style + disp_badge = unmatched_tag if (show_tag and unmatched_tag) else None + elif dim_unmatched: + case, disp_badge = 'dim', None + else: + case, disp_badge = 'plain', None + + render_info.append((case, ansi, badge_label, ns, pgroups, sgroups, name, type_str)) + pub_vws.append(_vis_pub_w(pgroups)) + mid_vws.append(_vis_mid_w(disp_badge, name, type_str)) + + max_pub_w = max(pub_vws, default=0) + max_mid_w = max(mid_vws, default=0) + has_subs = any(sub_groups.get(t, []) for t in all_topics) + + # ── Pass 2: render with aligned columns ─────────────────────────────────── + for case, ansi, badge_label, ns, pgroups, sgroups, name, type_str in render_info: + if case == 'empty': + sys.stdout.write('\n') + sys.stdout.flush() + continue + + type_part = f' [\033[2m{type_str}{RESET}]' if type_str else '' + + # System topic: plain, indented to the topic-name column + if case == 'system': + col_offset = (max_pub_w + 1) if max_pub_w > 0 else 0 + sys.stdout.write(_INDENT + ' ' * col_offset + name + type_part + '\n') + sys.stdout.flush() + continue + + # Left column: pub blocks right-aligned in max_pub_w chars + pub_vis = _vis_pub_w(pgroups) + if max_pub_w > 0: + pub_section = ' ' * (max_pub_w - pub_vis) + _count_blocks(pgroups) + ' ' + else: + pub_section = '' + + # Middle column: [badge] colored_name [type] + disp_badge = badge_label if (show_tag and badge_label) else None + if case in ('matched', 'unmatched'): + colored = f'\033[{ansi}m{name}{RESET}' + mid = ((_badge(badge_label, ansi, ns) + ' ') if disp_badge else '') + colored + type_part + elif case == 'dim': + mid = f'\033[2m{name}{RESET}{type_part}' + else: + mid = name + type_part + + mid_vis = _vis_mid_w(disp_badge, name, type_str) + + # Right column: sub blocks, aligned across all lines + sub_block = _count_blocks(sgroups) + if has_subs and sub_block: + sub_section = ' ' * (max_mid_w - mid_vis) + ' ' + sub_block + else: + sub_section = '' + + sys.stdout.write(_INDENT + pub_section + mid + sub_section + '\n') + sys.stdout.flush() + + +if __name__ == '__main__': + main() diff --git a/install.sh b/install.sh index eeac30b..079a742 100755 --- a/install.sh +++ b/install.sh @@ -52,6 +52,7 @@ $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_node_info.py" "$INSTALL_DIR/" $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_node_list.py" "$INSTALL_DIR/" $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_service_list.py" "$INSTALL_DIR/" $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_action_list.py" "$INSTALL_DIR/" +$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_topic_list.py" "$INSTALL_DIR/" $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_param_list.py" "$INSTALL_DIR/" $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_param_describe.py" "$INSTALL_DIR/" $SUDO cp "${SCRIPT_DIR}/dendROS/dendROS.sh" "$INSTALL_DIR/" @@ -63,6 +64,7 @@ $SUDO chmod +x "$INSTALL_DIR/dendros_node_list.py" $SUDO chmod +x "$INSTALL_DIR/dendros_node_info.py" $SUDO chmod +x "$INSTALL_DIR/dendros_service_list.py" $SUDO chmod +x "$INSTALL_DIR/dendros_action_list.py" +$SUDO chmod +x "$INSTALL_DIR/dendros_topic_list.py" $SUDO chmod +x "$INSTALL_DIR/dendros_param_list.py" $SUDO chmod +x "$INSTALL_DIR/dendros_param_describe.py" $SUDO chmod 644 "$INSTALL_DIR/dendROS.sh" diff --git a/test/unit/test_topic_list.py b/test/unit/test_topic_list.py new file mode 100644 index 0000000..c0f3a16 --- /dev/null +++ b/test/unit/test_topic_list.py @@ -0,0 +1,513 @@ +"""Tests for dendros_topic_list.py colorization.""" + +import json +import os +import sys +import subprocess + +import pytest +import yaml + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +TOPIC_LIST_PATH = os.path.join(REPO_ROOT, 'dendROS', 'dendros_topic_list.py') + +from conftest import assert_segment_colored, assert_segment_uncolored, colored_segments, strip_ansi + + +# ── Helper ──────────────────────────────────────────────────────────────────── + +def run_topic_list(tmp_prefix, topics, global_cfg=None, node_colors=None, + pub_nodes=None, sub_nodes=None, timeout=10): + """Run dendros_topic_list.py with topic names as stdin; return (stdout, stderr, rc).""" + env = os.environ.copy() + env['AMENT_PREFIX_PATH'] = tmp_prefix + env.pop('ROS_DISTRO', None) + env['HOME'] = tmp_prefix + + cfg_dir = os.path.join(tmp_prefix, '.config', 'dendROS') + os.makedirs(cfg_dir, exist_ok=True) + + if global_cfg: + with open(os.path.join(cfg_dir, 'defaults.yaml'), 'w') as f: + yaml.dump(global_cfg, f) + if node_colors: + with open(os.path.join(cfg_dir, 'node_colors.yaml'), 'w') as f: + yaml.dump(node_colors, f) + + if pub_nodes is not None: + env['DENDROS_TOPIC_PUBLISHERS'] = json.dumps(pub_nodes) + if sub_nodes is not None: + env['DENDROS_TOPIC_SUBSCRIBERS'] = json.dumps(sub_nodes) + + stdin = '\n'.join(topics) + '\n' + result = subprocess.run( + [sys.executable, TOPIC_LIST_PATH], + input=stdin.encode(), + capture_output=True, + env=env, + timeout=timeout, + ) + return result.stdout.decode(), result.stderr.decode(), result.returncode + + +def _line_for(stdout, topic): + """Return the output line containing `topic`.""" + return next(l for l in stdout.splitlines() if topic in l) + + +def _name_col(line, topic): + """Column of `topic` in the plain-text version of `line`.""" + return strip_ansi(line).index(topic) + + +# ── Publisher color resolution ──────────────────────────────────────────────── + +class TestTopicListPublisherColor: + """Topic is colored with the primary publisher node's group color.""" + + def test_topic_colored_by_publisher(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + assert_segment_colored(stdout, '/chatter', '32') + + def test_no_publisher_no_color(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': []}, + sub_nodes={'/chatter': []}) + assert_segment_uncolored(stdout, '/chatter') + + def test_primary_publisher_wins_color(self, tmp_path): + nc = {'color_map': {'talker': '32', 'other': '33'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker', 'other']}, + sub_nodes={'/chatter': []}) + assert_segment_colored(stdout, '/chatter', '32') + + def test_wildcard_publisher_color(self, tmp_path): + nc = {'color_map': {'nav2_*': '35'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/plan'], + node_colors=nc, + pub_nodes={'/plan': ['nav2_planner']}, + sub_nodes={'/plan': []}) + assert_segment_colored(stdout, '/plan', '35') + + def test_empty_lines_preserved(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter', '', '/other'], + node_colors=nc, + pub_nodes={'/chatter': ['talker'], '/other': []}, + sub_nodes={'/chatter': [], '/other': []}) + assert '' in stdout.splitlines() + + def test_output_indented(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + for line in stdout.splitlines(): + if line: + assert line.startswith(' '), f"Expected 2-space indent, got: {line!r}" + + +# ── Column alignment ────────────────────────────────────────────────────────── + +class TestTopicListAlignment: + """Topic names start at the same column regardless of pub block count.""" + + def test_names_aligned_across_different_pub_counts(self, tmp_path): + nc = {'color_map': {'talker': '32', 'nav2_*': '34'}, + 'tag_map': {}, 'style_map': {}} + # /chatter: 1 pub group → 1 block; /plan: 2 pub groups → "1 1" + stdout, _, _ = run_topic_list( + str(tmp_path), ['/chatter', '/plan'], + node_colors=nc, + pub_nodes={'/chatter': ['talker'], + '/plan': ['nav2_planner', 'talker']}, + sub_nodes={'/chatter': [], '/plan': []}, + ) + col_chatter = _name_col(_line_for(stdout, '/chatter'), '/chatter') + col_plan = _name_col(_line_for(stdout, '/plan'), '/plan') + assert col_chatter == col_plan + + def test_sub_blocks_aligned_across_lines(self, tmp_path): + nc = {'color_map': {'talker': '32', 'listener': '33'}, + 'tag_map': {}, 'style_map': {}} + # /chatter: short name; /longer_topic: long name — subs still start at same column + stdout, _, _ = run_topic_list( + str(tmp_path), ['/chatter', '/longer_topic'], + node_colors=nc, + pub_nodes={'/chatter': ['talker'], + '/longer_topic': ['talker']}, + sub_nodes={'/chatter': ['listener'], + '/longer_topic': ['listener']}, + ) + # Find the byte offset of the sub block in each plain-text line + def sub_col(line): + plain = strip_ansi(line) + # sub block follows the topic name; find the digit after topic+padding + topic_end = plain.rstrip().rfind('1') # count block digit + return topic_end + + chatter_sub = sub_col(_line_for(stdout, '/chatter')) + longer_sub = sub_col(_line_for(stdout, '/longer_topic')) + assert chatter_sub == longer_sub + + def test_system_topic_name_at_same_column_as_others(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list( + str(tmp_path), ['/chatter', '/parameter_events'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}, + ) + col_chatter = _name_col(_line_for(stdout, '/chatter'), '/chatter') + col_pe = _name_col(_line_for(stdout, '/parameter_events'), '/parameter_events') + assert col_chatter == col_pe + + def test_no_pub_topic_name_at_same_column(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + # /scan has pubs, /cmd_vel has none — both names should start at same column + stdout, _, _ = run_topic_list( + str(tmp_path), ['/scan', '/cmd_vel'], + node_colors=nc, + pub_nodes={'/scan': ['talker'], '/cmd_vel': []}, + sub_nodes={'/scan': [], '/cmd_vel': []}, + ) + col_scan = _name_col(_line_for(stdout, '/scan'), '/scan') + col_cmd_vel = _name_col(_line_for(stdout, '/cmd_vel'), '/cmd_vel') + assert col_scan == col_cmd_vel + + +# ── System topics ───────────────────────────────────────────────────────────── + +class TestSystemTopics: + """/parameter_events and /rosout shown plain — no color, tag, or count blocks.""" + + def test_parameter_events_no_ansi(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), + ['/chatter', '/parameter_events'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + pe_line = _line_for(stdout, '/parameter_events') + assert '\033[' not in pe_line + + def test_rosout_no_ansi(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter', '/rosout'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + rosout_line = _line_for(stdout, '/rosout') + assert '\033[' not in rosout_line + + def test_system_topic_no_tag(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/parameter_events'], + node_colors=nc, + pub_nodes={}, sub_nodes={}) + assert '[TLK]' not in stdout + + def test_system_topic_no_count_blocks(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + # System topics are excluded from graph queries; no count blocks appear + stdout, _, _ = run_topic_list(str(tmp_path), ['/rosout'], + node_colors=nc, + pub_nodes={}, sub_nodes={}) + assert ';7m' not in stdout + + def test_system_topic_still_appears(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter', '/rosout'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + assert '/rosout' in stdout + + def test_system_topic_indented(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/parameter_events'], + node_colors=nc, + pub_nodes={}, sub_nodes={}) + pe_line = _line_for(stdout, '/parameter_events') + assert pe_line.startswith(' ') + + def test_system_topic_type_dimmed_with_t_flag(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + line = '/parameter_events [rcl_interfaces/msg/ParameterEvent]' + stdout, _, _ = run_topic_list(str(tmp_path), [line], + node_colors=nc, + pub_nodes={}, sub_nodes={}) + # Type should still be dim even for system topics + assert '[\033[2mrcl_interfaces/msg/ParameterEvent\033[0m]' in stdout + # But the topic name itself has no color code + pe_line = _line_for(stdout, '/parameter_events') + assert not pe_line.startswith('\033[') + + +# ── Count indicators ────────────────────────────────────────────────────────── + +class TestTopicListCounts: + """Publisher count blocks on left; subscriber count blocks on right.""" + + def test_single_pub_block_on_left(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + pub_block = '\033[32;7m1\033[0m' + assert pub_block in stdout + assert stdout.index(pub_block) < stdout.index('\033[32m/chatter\033[0m') + + def test_single_sub_block_on_right(self, tmp_path): + nc = {'color_map': {'talker': '32', 'listener': '33'}, + 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': ['listener']}) + sub_block = '\033[33;7m1\033[0m' + assert sub_block in stdout + assert stdout.index('\033[32m/chatter\033[0m') < stdout.index(sub_block) + + def test_multiple_pub_same_group_summed(self, tmp_path): + nc = {'color_map': {'talker*': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker', 'talker2']}, + sub_nodes={'/chatter': []}) + assert '\033[32;7m2\033[0m' in stdout + + def test_multiple_pub_different_groups(self, tmp_path): + nc = {'color_map': {'loc_node': '34', 'nav_node': '35'}, + 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/scan'], + node_colors=nc, + pub_nodes={'/scan': ['loc_node', 'nav_node']}, + sub_nodes={'/scan': []}) + assert '\033[34;7m1\033[0m' in stdout + assert '\033[35;7m1\033[0m' in stdout + + def test_multiple_sub_different_groups(self, tmp_path): + nc = {'color_map': {'talker': '32', 'loc': '34', 'nav': '35'}, + 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/scan'], + node_colors=nc, + pub_nodes={'/scan': ['talker']}, + sub_nodes={'/scan': ['loc', 'nav']}) + assert '\033[34;7m1\033[0m' in stdout + assert '\033[35;7m1\033[0m' in stdout + + def test_no_sub_no_trailing_blocks(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + line = _line_for(stdout, '/chatter') + plain = strip_ansi(line) + after = plain[plain.index('/chatter') + len('/chatter'):] + assert after.strip() == '' + + def test_unmatched_publisher_no_pub_block(self, tmp_path): + nc = {'color_map': {'known': '34'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['unknown_publisher']}, + sub_nodes={'/chatter': []}) + assert ';7m' not in stdout + + +# ── Tag badge ───────────────────────────────────────────────────────────────── + +class TestTopicListTag: + """Tag badge appears between pub count blocks and the topic name.""" + + def test_tag_shown_left_of_topic(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + assert '[TLK]' in stdout + line = _line_for(stdout, '/chatter') + assert line.index('[TLK]') < line.index('/chatter') + + def test_tag_between_pub_block_and_topic(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + pub_block = '\033[32;7m1\033[0m' + topic_colored = '\033[32m/chatter\033[0m' + assert pub_block in stdout + assert stdout.index(pub_block) < stdout.index('[TLK]') + assert stdout.index('[TLK]') < stdout.index(topic_colored) + + def test_tag_hidden_when_show_tag_cli_false(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}, + global_cfg={'show_tag_cli': False}) + assert '[TLK]' not in stdout + + def test_inverted_tag_style(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, + 'style_map': {'talker': 'inverted'}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + assert '\033[32;7m[TLK]' in stdout + + def test_empty_label_no_badge(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + assert '[]' not in strip_ansi(stdout) + + def test_unmatched_tag(self, tmp_path): + nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + node_colors=nc, + pub_nodes={'/chatter': []}, + sub_nodes={'/chatter': []}, + global_cfg={'unmatched_color': 'white', + 'unmatched_tag': '?'}) + assert '[?]' in stdout + + def test_tag_width_counted_in_alignment(self, tmp_path): + """Badge width is included in mid column, so sub blocks stay aligned.""" + nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}} + stdout, _, _ = run_topic_list( + str(tmp_path), ['/chatter', '/scan'], + node_colors=nc, + pub_nodes={'/chatter': ['talker'], '/scan': ['talker']}, + sub_nodes={'/chatter': ['talker'], '/scan': ['talker']}, + ) + chatter_sub = strip_ansi(_line_for(stdout, '/chatter')).rstrip().rfind('1') + scan_sub = strip_ansi(_line_for(stdout, '/scan')).rstrip().rfind('1') + assert chatter_sub == scan_sub + + +# ── -t flag: type annotation dimmed ────────────────────────────────────────── + +class TestTopicListTypeFlag: + + def test_type_dimmed_matched_topic(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + line = '/chatter [std_msgs/msg/String]' + stdout, _, _ = run_topic_list(str(tmp_path), [line], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + assert_segment_colored(stdout, '/chatter', '32') + assert '[\033[2mstd_msgs/msg/String\033[0m]' in stdout + + def test_type_dimmed_unmatched_topic(self, tmp_path): + nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}} + line = '/unknown [std_msgs/msg/String]' + stdout, _, _ = run_topic_list(str(tmp_path), [line], + node_colors=nc, + pub_nodes={'/unknown': []}, + sub_nodes={'/unknown': []}) + assert '[\033[2mstd_msgs/msg/String\033[0m]' in stdout + + def test_type_not_colored_with_node_color(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + line = '/chatter [std_msgs/msg/String]' + stdout, _, _ = run_topic_list(str(tmp_path), [line], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': []}) + assert '\033[32mstd_msgs' not in stdout + + def test_sub_blocks_after_type(self, tmp_path): + nc = {'color_map': {'talker': '32', 'listener': '33'}, 'tag_map': {}, 'style_map': {}} + line = '/chatter [std_msgs/msg/String]' + stdout, _, _ = run_topic_list(str(tmp_path), [line], + node_colors=nc, + pub_nodes={'/chatter': ['talker']}, + sub_nodes={'/chatter': ['listener']}) + type_bracket = '[\033[2mstd_msgs/msg/String\033[0m]' + sub_block = '\033[33;7m1\033[0m' + assert stdout.index(type_bracket) < stdout.index(sub_block) + + +# ── Unmatched / dim_unmatched ───────────────────────────────────────────────── + +class TestTopicListUnmatched: + + def test_unmatched_color_applied(self, tmp_path): + nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/unknown'], + node_colors=nc, + pub_nodes={'/unknown': []}, + sub_nodes={'/unknown': []}, + global_cfg={'unmatched_color': 'cyan'}) + assert '\033[' in stdout + + def test_dim_unmatched(self, tmp_path): + nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/unknown'], + node_colors=nc, + pub_nodes={'/unknown': []}, + sub_nodes={'/unknown': []}, + global_cfg={'dim_unmatched': True}) + assert '\033[2m/unknown\033[0m' in stdout + + def test_passthrough_when_no_match(self, tmp_path): + nc = {'color_map': {'known': '34'}, 'tag_map': {}, 'style_map': {}} + stdout, _, _ = run_topic_list(str(tmp_path), ['/unknown'], + node_colors=nc, + pub_nodes={'/unknown': []}, + sub_nodes={'/unknown': []}) + assert_segment_uncolored(stdout, '/unknown') + + def test_passthrough_no_config(self, tmp_path): + stdout, _, _ = run_topic_list(str(tmp_path), ['/chatter'], + pub_nodes={'/chatter': []}, + sub_nodes={'/chatter': []}) + assert '/chatter' in stdout + assert '\033[' not in stdout + + def test_dim_unmatched_with_type(self, tmp_path): + nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}} + line = '/unknown [std_msgs/msg/String]' + stdout, _, _ = run_topic_list(str(tmp_path), [line], + node_colors=nc, + pub_nodes={'/unknown': []}, + sub_nodes={'/unknown': []}, + global_cfg={'dim_unmatched': True}) + assert '\033[2m/unknown\033[0m' in stdout + assert '[\033[2mstd_msgs/msg/String\033[0m]' in stdout + + +# ── AMENT_PREFIX_PATH fallback ──────────────────────────────────────────────── + +class TestTopicListFallback: + + def test_fallback_scan_loads_config(self, tmp_path): + cfg_dir = tmp_path / 'share' / 'my_pkg' / 'config' + cfg_dir.mkdir(parents=True) + (cfg_dir / 'dendROS.yaml').write_text(yaml.dump({ + 'groups': {'loc': {'color': 'bold blue', 'label': 'LOC', 'nodes': ['amcl']}} + })) + stdout, _, _ = run_topic_list(str(tmp_path), ['/scan'], + pub_nodes={'/scan': ['amcl']}, + sub_nodes={'/scan': []}) + assert '\033[' in stdout From d9f4ef9b6ffcfdefa186aa9057e1821002fa2a72 Mon Sep 17 00:00:00 2001 From: mlisi1 Date: Wed, 24 Jun 2026 18:54:27 +0200 Subject: [PATCH 2/4] added topics group filtering --- dendROS/dendros_config.py | 6 ++ dendROS/dendros_topic_list.py | 25 +++++++ dendROS/lib/global_config.py | 1 + test/unit/test_global_config.py | 2 + test/unit/test_topic_list.py | 118 ++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+) diff --git a/dendROS/dendros_config.py b/dendROS/dendros_config.py index 798c0ee..e6910c3 100644 --- a/dendROS/dendros_config.py +++ b/dendROS/dendros_config.py @@ -38,6 +38,7 @@ ("unmatched_tag", "Unmatched tag", "text", None), ("dim_unmatched", "Dim unmatched", "cycle", [False, True]), ("show_default_services", "Show default services", "cycle", [True, False]), + ("topic_sort", "Topic list sort", "cycle", ["default", "group"]), ("init_modify_build", "Init: modify build", "cycle", [True, False]), ("init_on_existing", "Init: on existing", "cycle", ["abort", "merge", "overwrite"]), ("init_color", "Init: color", "cycle", ["palette", "null"]), @@ -71,6 +72,11 @@ "on — include standard parameter/logger services in ros2 service list (shown dimmed)", "off — hide describe_parameters, get_parameters, set_parameters, get_loggers … from ros2 service list", ), + "topic_sort": ( + "default — show topics in the order ros2 reports them (alphabetical by ROS 2)", + "group — system topics first, then topics grouped by publisher color group" + " (groups in first-occurrence order, alphabetical within each group)", + ), "tag_position": ( "after — badge appears after the prefix: [node-N] [TAG] [INFO] …", "before — badge appears before the prefix: [TAG] [node-N] [INFO] …", diff --git a/dendROS/dendros_topic_list.py b/dendROS/dendros_topic_list.py index eba877d..5e17697 100644 --- a/dendROS/dendros_topic_list.py +++ b/dendROS/dendros_topic_list.py @@ -108,6 +108,27 @@ def _split_type(line): return line, None +def _sort_by_group(render_info): + """Reorder render_info for topic_sort='group': + system topics first (original order), then matched topics grouped by color + (groups in first-occurrence order, alphabetical within group), then + unmatched/dim/plain topics alphabetically. Empty lines are dropped.""" + system = [x for x in render_info if x[0] == 'system'] + matched = [x for x in render_info if x[0] == 'matched'] + other = [x for x in render_info if x[0] not in ('empty', 'system', 'matched')] + + group_order = {} + for item in matched: + ansi = item[1] + if ansi not in group_order: + group_order[ansi] = len(group_order) + + matched_sorted = sorted(matched, key=lambda x: (group_order.get(x[1], 999), x[6])) + other_sorted = sorted(other, key=lambda x: x[6] or '') + + return system + matched_sorted + other_sorted + + def _fetch_from_env(item_set, env_key): ov = os.environ.get(env_key) if ov is None: @@ -126,6 +147,7 @@ def main(): unmatched_clr = cfg['unmatched_color'] unmatched_tag = cfg['unmatched_tag'] dim_unmatched = cfg['dim_unmatched'] + topic_sort = cfg.get('topic_sort', 'default') unmatched_ansi = _resolve_color(unmatched_clr) if unmatched_clr else None color_map, tag_map, style_map = _load_shared_colors() @@ -230,6 +252,9 @@ def main(): max_mid_w = max(mid_vws, default=0) has_subs = any(sub_groups.get(t, []) for t in all_topics) + if topic_sort == 'group': + render_info = _sort_by_group(render_info) + # ── Pass 2: render with aligned columns ─────────────────────────────────── for case, ansi, badge_label, ns, pgroups, sgroups, name, type_str in render_info: if case == 'empty': diff --git a/dendROS/lib/global_config.py b/dendROS/lib/global_config.py index 3000924..9bd88b1 100644 --- a/dendROS/lib/global_config.py +++ b/dendROS/lib/global_config.py @@ -33,6 +33,7 @@ "traceback_color": "fancy", "tag_style": "normal", "show_default_services": True, + "topic_sort": "default", "param_change_alert": True, "param_change_alert_scope": "tracked", "param_change_alert_style": "inline", diff --git a/test/unit/test_global_config.py b/test/unit/test_global_config.py index e4e4f70..cf06ef0 100644 --- a/test/unit/test_global_config.py +++ b/test/unit/test_global_config.py @@ -90,6 +90,7 @@ def test_loads_existing_file(self, tmp_config): "traceback_color": "red", "tag_style": "inverted", "show_default_services": False, + "topic_sort": "default", "param_change_alert": True, "param_change_alert_scope": "all", "param_change_alert_style": "inverted", @@ -175,6 +176,7 @@ def test_roundtrip_custom_values(self, tmp_config): "traceback_color": "off", "tag_style": "inverted", "show_default_services": False, + "topic_sort": "group", "param_change_alert": True, "param_change_alert_scope": "all", "param_change_alert_style": "inverted", diff --git a/test/unit/test_topic_list.py b/test/unit/test_topic_list.py index c0f3a16..3c0fa69 100644 --- a/test/unit/test_topic_list.py +++ b/test/unit/test_topic_list.py @@ -511,3 +511,121 @@ def test_fallback_scan_loads_config(self, tmp_path): pub_nodes={'/scan': ['amcl']}, sub_nodes={'/scan': []}) assert '\033[' in stdout + + +# ── topic_sort ──────────────────────────────────────────────────────────────── + +class TestTopicListSort: + """topic_sort=group: system topics first, then by publisher group, then unmatched.""" + + # Helper that returns the plain-text order of topic names in stdout + @staticmethod + def _order(stdout): + result = [] + for line in stdout.splitlines(): + plain = strip_ansi(line).strip() + if not plain: + continue + # Topic name is the first token starting with '/' + # (pub count blocks and badge come before it) + for token in plain.split(): + if token.startswith('/'): + result.append(token) + break + return result + + def test_default_sort_preserves_ros2_order(self, tmp_path): + nc = {'color_map': {'talker': '32', 'nav': '34'}, 'tag_map': {}, 'style_map': {}} + topics = ['/zebra', '/alpha', '/middle'] + stdout, _, _ = run_topic_list(str(tmp_path), topics, + node_colors=nc, + pub_nodes={'/zebra': ['talker'], '/alpha': ['nav'], + '/middle': ['talker']}, + sub_nodes={t: [] for t in topics}) + order = self._order(stdout) + assert order == ['/zebra', '/alpha', '/middle'] + + def test_group_sort_topics_grouped_by_color(self, tmp_path): + nc = {'color_map': {'loc': '34', 'nav': '35'}, 'tag_map': {}, 'style_map': {}} + # Input order: nav topic, then loc topic, then another nav topic + topics = ['/cmd_vel', '/scan', '/plan'] + pub = {'/cmd_vel': ['nav'], '/scan': ['loc'], '/plan': ['nav']} + stdout, _, _ = run_topic_list(str(tmp_path), topics, + node_colors=nc, + pub_nodes=pub, + sub_nodes={t: [] for t in topics}, + global_cfg={'topic_sort': 'group'}) + order = self._order(stdout) + # /cmd_vel and /plan (nav group, first seen) before /scan (loc group) + cmd_i = order.index('/cmd_vel') + plan_i = order.index('/plan') + scan_i = order.index('/scan') + assert cmd_i < scan_i and plan_i < scan_i + + def test_group_sort_alphabetical_within_group(self, tmp_path): + nc = {'color_map': {'nav': '35'}, 'tag_map': {}, 'style_map': {}} + topics = ['/zebra', '/alpha', '/middle'] + pub = {t: ['nav'] for t in topics} + stdout, _, _ = run_topic_list(str(tmp_path), topics, + node_colors=nc, + pub_nodes=pub, + sub_nodes={t: [] for t in topics}, + global_cfg={'topic_sort': 'group'}) + order = self._order(stdout) + assert order == ['/alpha', '/middle', '/zebra'] + + def test_group_sort_system_topics_first(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + topics = ['/chatter', '/parameter_events', '/scan', '/rosout'] + stdout, _, _ = run_topic_list(str(tmp_path), topics, + node_colors=nc, + pub_nodes={'/chatter': ['talker'], '/scan': ['talker']}, + sub_nodes={t: [] for t in topics + if t not in ('/parameter_events', '/rosout')}, + global_cfg={'topic_sort': 'group'}) + order = self._order(stdout) + # /parameter_events and /rosout come before any application topic + app_indices = [order.index(t) for t in ['/chatter', '/scan'] if t in order] + sys_indices = [order.index(t) for t in ['/parameter_events', '/rosout'] if t in order] + assert all(s < a for s in sys_indices for a in app_indices) + + def test_group_sort_unmatched_topics_last(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + topics = ['/unknown', '/chatter'] + pub = {'/unknown': [], '/chatter': ['talker']} + stdout, _, _ = run_topic_list(str(tmp_path), topics, + node_colors=nc, + pub_nodes=pub, + sub_nodes={t: [] for t in topics}, + global_cfg={'topic_sort': 'group'}) + order = self._order(stdout) + assert order.index('/chatter') < order.index('/unknown') + + def test_group_sort_empty_lines_dropped(self, tmp_path): + nc = {'color_map': {'talker': '32'}, 'tag_map': {}, 'style_map': {}} + topics = ['/chatter', '', '/scan'] + pub = {'/chatter': ['talker'], '/scan': ['talker']} + stdout, _, _ = run_topic_list(str(tmp_path), topics, + node_colors=nc, + pub_nodes=pub, + sub_nodes={'/chatter': [], '/scan': []}, + global_cfg={'topic_sort': 'group'}) + # In group mode, empty lines are dropped + non_empty = [l for l in stdout.splitlines() if l] + assert len(non_empty) == 2 + + def test_group_sort_groups_in_first_occurrence_order(self, tmp_path): + nc = {'color_map': {'nav': '35', 'loc': '34'}, 'tag_map': {}, 'style_map': {}} + # nav topic appears first in input → nav group comes first in sorted output + topics = ['/cmd_vel', '/scan', '/plan'] + pub = {'/cmd_vel': ['nav'], '/scan': ['loc'], '/plan': ['nav']} + stdout, _, _ = run_topic_list(str(tmp_path), topics, + node_colors=nc, + pub_nodes=pub, + sub_nodes={t: [] for t in topics}, + global_cfg={'topic_sort': 'group'}) + order = self._order(stdout) + # nav group (first seen) → loc group + first_nav = min(order.index(t) for t in ['/cmd_vel', '/plan']) + first_loc = order.index('/scan') + assert first_nav < first_loc From 97c1240e4a291c462aca6c76425bc34cb72f5565 Mon Sep 17 00:00:00 2001 From: mlisi1 Date: Wed, 24 Jun 2026 19:02:26 +0200 Subject: [PATCH 3/4] added topic sorting --- dendROS/dendros_topic_list.py | 32 +++++++++++++++-- test/unit/test_topic_list.py | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/dendROS/dendros_topic_list.py b/dendROS/dendros_topic_list.py index 5e17697..b23a119 100644 --- a/dendROS/dendros_topic_list.py +++ b/dendROS/dendros_topic_list.py @@ -3,6 +3,7 @@ import json import os +import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -22,6 +23,29 @@ # System topics shown plain (no color, tag, or count blocks) _SYSTEM_TOPICS = {'/parameter_events', '/rosout'} +_SECTION_RE = re.compile(r'^(Published|Subscribed) topics:$') +_VERBOSE_TOPIC_RE = re.compile(r'^( \* )(/\S+)( \[)([^\]]+)(\].*)$') + + +def _is_verbose(raw_lines): + return any(_SECTION_RE.match(l.rstrip('\n')) for l in raw_lines) + + +def _format_verbose(raw_lines): + """Apply minimal formatting to ros2 topic list -v output.""" + for raw in raw_lines: + line = raw.rstrip('\n') + if _SECTION_RE.match(line): + sys.stdout.write(f'\033[1m{line}\033[0m\n') + else: + m = _VERBOSE_TOPIC_RE.match(line) + if m: + bullet, name, bracket, type_str, rest = m.groups() + sys.stdout.write(f'{bullet}{name}{bracket}\033[2m{type_str}\033[0m{rest}\n') + else: + sys.stdout.write(line + '\n') + sys.stdout.flush() + def _load_shared_colors(): if yaml is None: @@ -141,6 +165,11 @@ def _fetch_from_env(item_set, env_key): def main(): + raw_lines = [line for line in sys.stdin] + if _is_verbose(raw_lines): + _format_verbose(raw_lines) + return + cfg = load_global_config() show_tag = cfg['show_tag_cli'] tag_style = cfg['tag_style'] @@ -167,11 +196,10 @@ def main(): ) # ── Parse input ─────────────────────────────────────────────────────────── - raw_lines = [line.rstrip('\n') for line in sys.stdin] all_topics = set() # excludes system topics parsed = [] # [(raw, name_or_None, type_str_or_None)] - for raw in raw_lines: + for raw in [l.rstrip('\n') for l in raw_lines]: if not raw: parsed.append((raw, None, None)) continue diff --git a/test/unit/test_topic_list.py b/test/unit/test_topic_list.py index 3c0fa69..f7db608 100644 --- a/test/unit/test_topic_list.py +++ b/test/unit/test_topic_list.py @@ -497,6 +497,71 @@ def test_dim_unmatched_with_type(self, tmp_path): assert '[\033[2mstd_msgs/msg/String\033[0m]' in stdout +# ── topic list -v (verbose) ─────────────────────────────────────────────────── + +class TestTopicListVerbose: + + def _run(self, tmp_path, lines): + return run_topic_list(str(tmp_path), lines) + + def test_section_headers_bold(self, tmp_path): + stdout, _, _ = self._run(tmp_path, [ + 'Published topics:', + ' * /chatter [std_msgs/msg/String] 1 publisher', + ]) + assert '\033[1mPublished topics:\033[0m' in stdout + + def test_subscribed_header_bold(self, tmp_path): + stdout, _, _ = self._run(tmp_path, [ + 'Subscribed topics:', + ' * /chatter [std_msgs/msg/String] 1 subscriber', + ]) + assert '\033[1mSubscribed topics:\033[0m' in stdout + + def test_type_dimmed(self, tmp_path): + stdout, _, _ = self._run(tmp_path, [ + 'Published topics:', + ' * /chatter [std_msgs/msg/String] 1 publisher', + ]) + assert '[\033[2mstd_msgs/msg/String\033[0m]' in stdout + + def test_topic_name_uncolored(self, tmp_path): + stdout, _, _ = self._run(tmp_path, [ + 'Published topics:', + ' * /chatter [std_msgs/msg/String] 1 publisher', + ]) + line = next(l for l in stdout.splitlines() if '/chatter' in l) + plain = strip_ansi(line) + assert plain.startswith(' * /chatter') + + def test_count_preserved(self, tmp_path): + stdout, _, _ = self._run(tmp_path, [ + 'Published topics:', + ' * /chatter [std_msgs/msg/String] 3 publishers', + ]) + assert '3 publishers' in strip_ansi(stdout) + + def test_empty_lines_pass_through(self, tmp_path): + stdout, _, _ = self._run(tmp_path, [ + 'Published topics:', + ' * /chatter [std_msgs/msg/String] 1 publisher', + '', + 'Subscribed topics:', + ' * /chatter [std_msgs/msg/String] 1 subscriber', + ]) + lines = stdout.splitlines() + assert '' in lines + + def test_multiple_topics(self, tmp_path): + stdout, _, _ = self._run(tmp_path, [ + 'Published topics:', + ' * /chatter [std_msgs/msg/String] 1 publisher', + ' * /scan [sensor_msgs/msg/LaserScan] 1 publisher', + ]) + assert '[\033[2mstd_msgs/msg/String\033[0m]' in stdout + assert '[\033[2msensor_msgs/msg/LaserScan\033[0m]' in stdout + + # ── AMENT_PREFIX_PATH fallback ──────────────────────────────────────────────── class TestTopicListFallback: From cb193f0fff20e155f9d7ce231e99270704f34682 Mon Sep 17 00:00:00 2001 From: mlisi1 Date: Thu, 25 Jun 2026 10:24:46 +0200 Subject: [PATCH 4/4] updated docs and readme --- README.md | 43 +------ .../images/screenshots/topic_list_sorted.png | Bin 0 -> 34550 bytes .../screenshots/topic_list_unsorted.png | Bin 0 -> 32035 bytes docs/global-config.md | 4 +- docs/index.md | 36 +----- docs/reference.md | 2 + docs/topic-list.md | 121 ++++++++++++++++++ mkdocs.yml | 1 + 8 files changed, 133 insertions(+), 74 deletions(-) create mode 100644 docs/assets/images/screenshots/topic_list_sorted.png create mode 100644 docs/assets/images/screenshots/topic_list_unsorted.png create mode 100644 docs/topic-list.md diff --git a/README.md b/README.md index f3f1b76..2d91302 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ It also features some quality of life improvements for ROS outputs. Colored Terminal Output

+- ### **All main `ros2` CLI commands wrapped** + `ros2 node list`, `ros2 node info`, `ros2 service list`, `ros2 action list`, `ros2 topic list`, `ros2 param list`, and `ros2 param describe` — all colorized with the same group colors and badges, no extra config required. [→ Full feature list](https://mlisi1.github.io/DendROS/node-list/#ros2--intercepted-subcommands) + - ### **One command to get started** Too lazy to look up how DendROS config works? We got you covered: `dendros init` scans your launch files and generates an initial config for you @@ -76,47 +79,7 @@ It also features some quality of life improvements for ROS outputs.

-- ### **```ros2 node list``` coloring** - Brings color to your node lists. - -

-ros2 node list -

- -- ### **```ros2 node info``` coloring** - Colorizes node info output: the node name gets its group color and badge, section headers are bolded, input sections (Subscribers, Service Clients, Action Clients) are colored with the provider's group color, and output sections (Publishers, Service Servers, Action Servers) with the node's own color. Live topic indicators show connected subscriber and publisher counts per group. - -

-ros2 node info -

- -- ### **```ros2 service list``` coloring** - Services are colored by their owning node's group color. Standard ROS 2 system services (`set_parameters`, `get_parameters`, `get_loggers`, …) are shown dimmed so your own services stand out. They can be hidden entirely via `show_default_services: false`. - -

-ros2 service list -

-- ### **```ros2 action list``` coloring** - Actions are colored by their owning node's group color, with the same badge and style options available in all other CLI commands. - -

-ros2 action list -

- -- ### **```ros2 param list``` coloring** - Node headers are colored with the group badge; parameter names are rendered dim so the structure is easy to scan. `--param-type` type annotations are dimmed automatically. - -

-ros2 param list -

- -- ### **```ros2 param describe``` coloring** - The group badge and parameter name are highlighted; field labels (`Type:`, `Description:`) are dimmed; section headers like `Constraints:` are bolded so the output structure is immediately readable. - -

-ros2 param describe -

- ### **Parameter change alert** When a node's parameter changes at runtime — via `ros2 param set` or any parameter service client — an inline notification appears in the launch terminal showing the node, parameter name, and old→new value. Two styles: compact inline and a full-width inverted block that's hard to miss in busy logs. diff --git a/docs/assets/images/screenshots/topic_list_sorted.png b/docs/assets/images/screenshots/topic_list_sorted.png new file mode 100644 index 0000000000000000000000000000000000000000..b84c151ac44f490a5478adc54e2b8d2ae403c556 GIT binary patch literal 34550 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 A+yDRo literal 0 HcmV?d00001 diff --git a/docs/assets/images/screenshots/topic_list_unsorted.png b/docs/assets/images/screenshots/topic_list_unsorted.png new file mode 100644 index 0000000000000000000000000000000000000000..6453e4e6b48fcbf3a8d04534fc4ba5fd665db068 GIT binary patch literal 32035 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