From e8accd8eba371f97abc0ea837293cc627a1fab75 Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Sun, 12 Apr 2026 20:57:00 -0700 Subject: [PATCH 1/6] Deduplicate frames in Unproto chat but show the repeated nodes in a different color. Allow aborting a connection when connecting instead of just timing out. --- paracon/paracon.def | 1 + paracon/paracon.py | 154 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 142 insertions(+), 13 deletions(-) diff --git a/paracon/paracon.def b/paracon/paracon.def index 58c770d..a9340f7 100644 --- a/paracon/paracon.def +++ b/paracon/paracon.def @@ -18,6 +18,7 @@ via = port = -1 color = true netrom = true +dedup = true [Logging] logdir = . diff --git a/paracon/paracon.py b/paracon/paracon.py index f96a691..2dbd30f 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -76,6 +76,7 @@ ('monitor_text', 'white', 'black'), ('monitor_call', 'light green', 'black'), ('monitor_own', 'light magenta', 'black'), + ('monitor_relayed', 'yellow', 'black'), # Connections ('connection_inbound', 'light cyan', 'black'), @@ -196,7 +197,18 @@ def port_for_index(self, ix): """, re.VERBOSE) -def _color_info_line(text, own=False): +def _last_starred_via(via_str): + """Return the base callsign (no *) of the last H-bit-set repeater, or None.""" + if not via_str: + return None + for via in reversed(via_str.split(',')): + via = via.strip() + if via.endswith('*'): + return via[:-1] + return None + + +def _color_info_line(text, own=False, count=0, heard_repeaters=None): monitor_call = 'monitor_own' if own else 'monitor_call' text = text.rstrip('\x00').rstrip() m = _INFO_LINE_PATTERN.match(text) @@ -212,11 +224,18 @@ def _color_info_line(text, own=False): vias = m['call_via'].split(',') line.append(('monitor_text', " Via ")) for via in vias: - line.append((monitor_call, via)) + via = via.strip() + base = via.rstrip('*') + if heard_repeaters and base in heard_repeaters: + line.append(('monitor_relayed', base + '*')) + else: + line.append((monitor_call, via)) line.append(('monitor_text', ',')) line = line[:-1] + count_str = " (x{})".format(count) if count > 1 else "" line.append( - ('monitor_text', " <{}>[{}]".format(m['msg_info'], m['msg_time']))) + ('monitor_text', " <{}>{}[{}]".format( + m['msg_info'], count_str, m['msg_time']))) return line @@ -226,11 +245,19 @@ def __init__(self): self._list = SizeListBox(self._log) self._queue = None self._periodic_key = None + self._pending_unproto = None # (kind, port, raw_text, clr_line) + self._last_unproto = None # {key, data, widget, time, count, own} for prev packet + self._dedup = config.get_bool('Monitor', 'dedup') is not False super().__init__(self._list) self._log.set_logfile(app.log_dir / 'monitor.log') urwid.connect_signal(app, 'server_started', self._start_monitor) urwid.connect_signal(app, 'server_stopping', self._stop_monitor) + def set_dedup(self, value): + self._dedup = value + if not value: + self._last_unproto = None + def _start_monitor(self, server): self._queue = server.monitor_queue self._periodic_key = app.start_periodic(1.0, self._update_from_queue) @@ -245,22 +272,30 @@ def _update_from_queue(self, obj): while not self._queue.empty(): (kind, port, line) = self._queue.get() if (kind is pserver.MonitorType.UNPROTO_INFO - or kind is pserver.MonitorType.UNPROTO_OWN - or kind is pserver.MonitorType.CONN_INFO - or kind is pserver.MonitorType.SUPER_INFO): + or kind is pserver.MonitorType.UNPROTO_OWN): + self._flush_pending_unproto() clr_line = _color_info_line( line, kind is pserver.MonitorType.UNPROTO_OWN) + if not clr_line: + logger.debug("Coloring failed: {}".format(line)) + self._pending_unproto = (kind, port, line, clr_line) + elif kind is pserver.MonitorType.UNPROTO_TEXT: + self._process_unproto_text(port, line) + elif (kind is pserver.MonitorType.CONN_INFO + or kind is pserver.MonitorType.SUPER_INFO): + self._flush_pending_unproto() + clr_line = _color_info_line(line) if clr_line: self.add_line(clr_line) else: logger.debug("Coloring failed: {}".format(line)) self.add_line(line) - elif (kind is pserver.MonitorType.UNPROTO_TEXT - or kind is pserver.MonitorType.CONN_TEXT): - # self.add_line(urwidx.safe_string(line.rstrip())) + elif kind is pserver.MonitorType.CONN_TEXT: + self._flush_pending_unproto() self.add_multi_line(line) elif (kind is pserver.MonitorType.UNPROTO_NETROM or kind is pserver.MonitorType.CONN_NETROM): + self._flush_pending_unproto() if line[0] == 0xFF: # only handle routing broadcasts try: rb = ax25.netrom.RoutingBroadcast.unpack(line) @@ -276,12 +311,87 @@ def _update_from_queue(self, obj): d.mnemonic, d.best_neighbor, d.best_quality)) + elif (kind is pserver.MonitorType.UNPROTO_BINARY + or kind is pserver.MonitorType.CONN_BINARY): + self._flush_pending_unproto() return True + def _flush_pending_unproto(self): + if self._pending_unproto is None: + return + kind, port, raw_text, clr_line = self._pending_unproto + self._pending_unproto = None + self._last_unproto = None + if clr_line: + self.add_line(clr_line) + else: + self.add_line(raw_text) + + def _process_unproto_text(self, port, data_text): + pending = self._pending_unproto + self._pending_unproto = None + if pending is None: + self.add_multi_line(data_text) + return + kind, pport, raw_text, clr_line = pending + own = kind is pserver.MonitorType.UNPROTO_OWN + m = _INFO_LINE_PATTERN.match(raw_text.rstrip('\x00').rstrip()) + if m: + dupe_key = (pport, m['call_from'], m['call_to']) + data_normalized = data_text.strip('\x00').rstrip() + if self._dedup: + last = self._last_unproto + if (last is not None + and last['key'] == dupe_key + and last['data'] == data_normalized + and time.time() - last['time'] < 60.0): + # Consecutive duplicate - accumulate heard repeaters, update in-place + last['count'] += 1 + last['time'] = time.time() + newly_heard = _last_starred_via(m['call_via']) + if newly_heard: + last['heard_repeaters'].add(newly_heard) + new_clr = _color_info_line( + raw_text, own, + count=last['count'], + heard_repeaters=last['heard_repeaters']) + if new_clr and last['widget'] is not None: + last['widget'].original_widget.set_text( + urwidx.safe_text(new_clr)) + last['widget']._invalidate() + self._log._modified() + return + # New packet (or dedup disabled) + initial_heard = set() + newly_heard = _last_starred_via(m['call_via']) + if newly_heard: + initial_heard.add(newly_heard) + new_clr = _color_info_line( + raw_text, own, heard_repeaters=initial_heard) + widget = self.add_line(new_clr if new_clr else raw_text) + self.add_multi_line(data_text) + if self._dedup: + self._last_unproto = { + 'key': dupe_key, + 'data': data_normalized, + 'widget': widget, + 'time': time.time(), + 'count': 1, + 'own': own, + 'heard_repeaters': initial_heard, + } + else: + self._last_unproto = None + if clr_line: + self.add_line(clr_line) + else: + self.add_line(raw_text) + self.add_multi_line(data_text) + def add_line(self, line): # Skip if the ListBox has not yet been fully initialized if not self._list.size: - return + return None line = urwidx.safe_text(line) text = urwid.AttrMap(urwid.Text(line), 'monitor_text') # Save the state of visibility before appending new content @@ -291,6 +401,7 @@ def add_line(self, line): # user has not scrolled up to view earlier entries) if 'bottom' in ends_visible: self._list.set_focus(len(self._log) - 1, 'above') + return text def add_multi_line(self, text): text = text.rstrip('\x00').rstrip().replace('\r\n', '\r') @@ -512,6 +623,7 @@ def _make_connection(self, info): port, info.connect_as, info.connect_to, vias) self._connection = conn self._periodic_key = app.start_periodic(1.0, self._update_from_queue) + self._menubar.menu.enable(self.MenuCommand.DISCONNECT, True) self.add_line('Connecting to {} ...'.format(info.connect_to)) # Connection process will complete in _update_from_queue() @@ -597,7 +709,8 @@ def _update_from_queue(self, obj): self._format_duration()) self._connection_start = None else: - message = 'Disconnected' + message = ('connection_error', + 'Connection aborted') self.add_line(message) self._log.set_logfile(None) self._menubar.menu.enable( @@ -939,6 +1052,7 @@ def _save_setup(self, setup_info): host = config.get('Setup', 'host') port = config.get_int('Setup', 'port') call = config.get('Setup', 'callsign') + dedup = config.get_bool('Monitor', 'dedup') is not False changed = False restart = False # If callsign changed, we don't immediately set the new value anywhere, @@ -949,11 +1063,15 @@ def _save_setup(self, setup_info): if setup_info.host != host or setup_info.port != port: changed = True restart = True + if setup_info.dedup != dedup: + changed = True if changed: config.set('Setup', 'host', setup_info.host) config.set_int('Setup', 'port', setup_info.port) config.set('Setup', 'callsign', setup_info.call) + config.set_bool('Monitor', 'dedup', setup_info.dedup) config.save_config() + self._monitor_panel.set_dedup(setup_info.dedup) if restart: self._server.stop() self._server = None @@ -1158,7 +1276,8 @@ def read_config(self): host = config.get('Setup', 'host') port = config.get_int('Setup', 'port') call = config.get('Setup', 'callsign') - self._info = SetupDialog.SetupInfo(host, port, call) + dedup = config.get_bool('Monitor', 'dedup') is not False + self._info = SetupDialog.SetupInfo(host, port, call, dedup) def write_config(self): """ @@ -1168,6 +1287,7 @@ def write_config(self): config.set('Setup', 'host', self._info.host) config.set_int('Setup', 'port', self._info.port) config.set('Setup', 'callsign', self._info.call) + config.set_bool('Monitor', 'dedup', self._info.dedup) config.save_config() def ask_for_info(self): @@ -1300,6 +1420,7 @@ class SetupInfo(NamedTuple): host: str port: int call: str + dedup: bool = True def __init__(self, info=None): self._info = info @@ -1310,10 +1431,12 @@ def add_fields(self): host = self._info.host port = self._info.port call = self._info.call + dedup = self._info.dedup else: host = config.get('Setup', 'host') or '' port = config.get_int('Setup', 'port') or 0 call = config.get('Setup', 'callsign') or '' + dedup = config.get_bool('Monitor', 'dedup') is not False self.add_group('server', "AGWPE Server") self.add_edit_str_field( 'host', 'Host', group='server', value=host) @@ -1323,6 +1446,10 @@ def add_fields(self): self.add_edit_str_field( 'call', 'Callsign', group='callsign', value=call, filter=callsign_filter) + self.add_group('monitor', "Monitor") + self.add_dropdown_field( + 'dedup', 'Dedup unproto', ['Yes', 'No'], 0 if dedup else 1, + group='monitor') def validate(self): host = self.get_edit_str_value('host') @@ -1338,7 +1465,8 @@ def save(self): host = self.get_edit_str_value('host') port = self.get_edit_int_value('port') call = self.get_edit_str_value('call').upper() - info = self.SetupInfo(host, port, call) + dedup = self.get_dropdown_value('dedup')[0] == 0 + info = self.SetupInfo(host, port, call, dedup) urwid.emit_signal(self, 'setup_info', info) From 45f5fbf3fb6ec8577ff75d4b9a5df10c9846e280 Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Mon, 13 Apr 2026 21:55:04 -0700 Subject: [PATCH 2/6] Theme support Example themes are included in `themes` --- docs/userguide.rst | 201 +++++++++++++++++++++++++++++++++++++ paracon/paracon.py | 55 ++++++++-- themes/darkColor.cfg | 26 +++++ themes/darkColorDarker.cfg | 26 +++++ themes/lightMuted.cfg | 26 +++++ themes/vsCode.cfg | 27 +++++ 6 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 themes/darkColor.cfg create mode 100644 themes/darkColorDarker.cfg create mode 100644 themes/lightMuted.cfg create mode 100644 themes/vsCode.cfg diff --git a/docs/userguide.rst b/docs/userguide.rst index 44d2295..8860c87 100644 --- a/docs/userguide.rst +++ b/docs/userguide.rst @@ -244,6 +244,207 @@ add an entry like the following: This example specifies that the old Windows encoding, ``cp1252``, should be used as the alternate decoder instead of the default ``cp437``. +Color themes +~~~~~~~~~~~~ + +Paracon's colors can be customized by adding a ``[Theme]`` section to your +``paracon.cfg`` file. Each entry in this section overrides one of Paracon's +named color attributes by name. Attributes you do not specify retain their +default colors. Unrecognized attribute names are silently ignored. + +.. code-block:: ini + + [Theme] + monitor_call = light red, black + monitor_own = light cyan, black + menu_key = light yellow,bold, dark blue + +Each entry takes the form:: + + attribute_name = foreground, background + +The foreground and background are separated by a comma followed by a space +(``", "``). The foreground may include text modifiers joined with a plain +comma and no space, for example ``light cyan,bold`` or +``white,bold,underline``. The full form with a modifier therefore looks +like:: + + menu_key = light cyan,bold, dark blue + +If you want to change only the foreground while keeping the default background, +you may omit the background value entirely (no trailing comma):: + + attribute_name = foreground + +The following tables list all named attributes that can be overridden, along +with their default colors. + +*Interface elements* + +.. list-table:: + :header-rows: 1 + :widths: 25 20 20 35 + + * - Attribute + - Default foreground + - Default background + - Controls + * - ``menu_key`` + - ``light cyan,bold`` + - ``dark blue`` + - Highlighted shortcut letter in each menu command + * - ``menu_text`` + - ``white`` + - ``dark blue`` + - Regular menu bar text + * - ``tabbar_unsel`` + - ``black`` + - ``light gray`` + - Unselected connection tabs + * - ``tabbar_sel`` + - ``white,bold`` + - ``black`` + - The currently selected connection tab + * - ``dropdown_item`` + - ``white`` + - ``dark blue`` + - Items in a drop-down list + * - ``dropdown_sel`` + - ``yellow,bold`` + - ``dark blue`` + - The currently highlighted drop-down item + * - ``button_select`` + - ``white`` + - ``black`` + - Dialog buttons + * - ``button_focus`` + - ``black`` + - ``light gray`` + - The currently focused dialog button + * - ``dialog_back`` + - ``white`` + - ``dark blue`` + - Dialog background + * - ``dialog_header`` + - ``black`` + - ``light gray`` + - Dialog title bar + * - ``field_error`` + - ``light red`` + - ``dark blue`` + - Validation error messages in dialogs + +*Windows* + +.. list-table:: + :header-rows: 1 + :widths: 25 20 20 35 + + * - Attribute + - Default foreground + - Default background + - Controls + * - ``window_norm`` + - ``light gray`` + - ``black`` + - Unfocused window border + * - ``window_sel`` + - ``yellow`` + - ``black`` + - Focused window border + +*Monitor panel* + +.. list-table:: + :header-rows: 1 + :widths: 25 20 20 35 + + * - Attribute + - Default foreground + - Default background + - Controls + * - ``monitor_text`` + - ``white`` + - ``black`` + - Body text of monitor entries + * - ``monitor_call`` + - ``light green`` + - ``black`` + - Callsigns in received unproto frames + * - ``monitor_own`` + - ``light magenta`` + - ``black`` + - Your own transmitted frames + * - ``monitor_relayed`` + - ``yellow`` + - ``black`` + - Digipeaters that have relayed a packet and that we heard (those marked with ``*``) + * - ``monitor_frame`` + - ``dark cyan`` + - ``black`` + - Frame type descriptor and timestamp (e.g. ````, ``[21:52:43]``) + +*Connection and Unproto panels* + +.. list-table:: + :header-rows: 1 + :widths: 25 20 20 35 + + * - Attribute + - Default foreground + - Default background + - Controls + * - ``connection_inbound`` + - ``light cyan`` + - ``black`` + - Inbound connection status messages + * - ``connection_outbound`` + - ``light magenta`` + - ``black`` + - Outbound connection status messages + * - ``connection_error`` + - ``light red`` + - ``black`` + - Connection error messages + * - ``unproto_error`` + - ``light red`` + - ``black`` + - Unproto error messages + * - ``entry_line`` + - ``white`` + - ``black`` + - The text entry line at the bottom of the panel + +Available colors +^^^^^^^^^^^^^^^^ + +The 16 standard **foreground** colors are: + + ``black``, ``dark red``, ``dark green``, ``brown``, ``dark blue``, + ``dark magenta``, ``dark cyan``, ``light gray``, ``dark gray``, + ``light red``, ``light green``, ``yellow``, ``light blue``, + ``light magenta``, ``light cyan``, ``white`` + +The 8 standard **background** colors are: + + ``black``, ``dark red``, ``dark green``, ``brown``, ``dark blue``, + ``dark magenta``, ``dark cyan``, ``light gray`` + +For both foreground and background, the special value ``default`` instructs +Paracon to use the terminal's own default color. + +Text modifiers may be appended to a foreground color with a comma: +``bold``, ``underline``, ``standout``, ``italics``, ``blink``, +``strikethrough``. For example, ``light cyan,bold`` or +``white,bold,underline``. + +If your terminal supports 256 colors, high-color values of the form +``h0``–``h255`` may be used, along with color-cube shortcuts such as +``#000``–``#fff`` and grayscale entries such as ``g0``–``g100``. Terminals +with 24-bit (true color) support also accept full hex codes in the form +``#rrggbb``. Support for these extended color formats varies across terminal +programs. + .. _cli-options: Command-line options diff --git a/paracon/paracon.py b/paracon/paracon.py index 2dbd30f..3568ecd 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -77,6 +77,7 @@ ('monitor_call', 'light green', 'black'), ('monitor_own', 'light magenta', 'black'), ('monitor_relayed', 'yellow', 'black'), + ('monitor_frame', 'dark cyan', 'black'), # Connections ('connection_inbound', 'light cyan', 'black'), @@ -234,7 +235,7 @@ def _color_info_line(text, own=False, count=0, heard_repeaters=None): line = line[:-1] count_str = " (x{})".format(count) if count > 1 else "" line.append( - ('monitor_text', " <{}>{}[{}]".format( + ('monitor_frame', " <{}>{}[{}]".format( m['msg_info'], count_str, m['msg_time']))) return line @@ -910,11 +911,49 @@ def keypress(self, size, key): key = edit.keypress((size[0] - 2, ), key) return key - # ============================================================================= # Application # ============================================================================= +# Matches any high-color spec: #rrggbb (24-bit), #rgb (256-color cube +# shortcut), h0-h255 (256-color index), g0-g100 (grayscale index). +_HIGHCOLOR_RE = re.compile(r'#[0-9a-fA-F]{3,6}\b|(? 1 else entry[2] + if _HIGHCOLOR_RE.search(new_fg) or _HIGHCOLOR_RE.search(new_bg): + # Keep original 16-color pair as basic fallback; put the + # override values in the high-color slots (positions 4, 5). + entry = (name, entry[1], entry[2], 'default', new_fg, new_bg) + else: + entry = (name, new_fg, new_bg) + entry[3:] + result.append(entry) + return result, highcolor + + class MonitorLogHandler(logging.Handler): def __init__(self, level=logging.NOTSET): super().__init__(level) @@ -936,7 +975,7 @@ class MenuCommand(Enum): QUIT = 'Quit' def __init__(self): - self._palette = palette + self._palette, self._highcolor = _apply_theme(palette) self._loop = None self._last_mouse_press = 0 self._server = None @@ -1172,14 +1211,18 @@ def run(self): self._configure_logging() self._loop = urwid.MainLoop( self._create_widgets(), - palette=self._palette, + palette=[], pop_ups=True, input_filter=urwidx.mouse_double_press_filter, unhandled_input=self._unhandled_input) if not IS_WINDOWS: self._loop.screen.write(XTPUSHCOLORS) - self._loop.screen.set_terminal_properties(16) - self._loop.screen.reset_default_terminal_palette() + if self._highcolor: + self._loop.screen.set_terminal_properties(2**24) + else: + self._loop.screen.set_terminal_properties(16) + self._loop.screen.reset_default_terminal_palette() + self._loop.screen.register_palette(self._palette) self._loop.set_alarm_in(0, self._start_server) self._loop.run() if not IS_WINDOWS: diff --git a/themes/darkColor.cfg b/themes/darkColor.cfg new file mode 100644 index 0000000..fb29e8a --- /dev/null +++ b/themes/darkColor.cfg @@ -0,0 +1,26 @@ +# Add the [Theme] section below to paracon.cfg + +[Theme] +menu_key = #b54040,bold, #1f1f1f +menu_text = #d2a1a1, #252526 +tabbar_unsel = #858585, #252526 +tabbar_sel = white,bold, #505050 +dropdown_item = light gray, #1f1f1f +dropdown_sel = #cccccc,bold, #3c3c3c +button_select = light gray, #1f1f1f +button_focus = #cccccc,bold, #3c3c3c +dialog_back = light gray, #1f1f1f +dialog_header = white,bold, #af690d +field_error = #f44747, #252526 +window_norm = #945f5f, #1f1f1f +window_sel = #6a886f, #1f1f1f +monitor_text = #cccccc, #1f1f1f +monitor_call = #80d0a3, #1f1f1f +monitor_own = #d9626d, #1f1f1f +monitor_relayed = #ffd77a, #1f1f1f +monitor_frame = dark gray, #1f1f1f +connection_inbound = #dddddd, #1f1f1f +connection_outbound = #d9626d, #1f1f1f +connection_error = #f44747, #1e1e1e +unproto_error = #f44747, #1e1e1e +entry_line = #d4d4d4, #3c3c3c \ No newline at end of file diff --git a/themes/darkColorDarker.cfg b/themes/darkColorDarker.cfg new file mode 100644 index 0000000..5cab769 --- /dev/null +++ b/themes/darkColorDarker.cfg @@ -0,0 +1,26 @@ +# Add the [Theme] section below to paracon.cfg + +[Theme] +menu_key = #d55656,bold, #162130 +menu_text = #cccccc, #162130 +tabbar_unsel = dark gray, #162130 +tabbar_sel = white,bold, #1d2b40 +dropdown_item = light gray, black +dropdown_sel = #cccccc,bold, #3c3c3c +button_select = light gray, black +button_focus = #cccccc,bold, #3c3c3c +dialog_back = light gray, black +dialog_header = white,bold, #755b03 +field_error = #f44747, #252526 +window_norm = #805252, black +window_sel = #9b8ba7, black +monitor_text = #cccccc, black +monitor_call = #379690, black +monitor_own = #d9626d, black +monitor_relayed = #fbbf24, black +monitor_frame = dark gray, black +connection_inbound = #dddddd, black +connection_outbound = #d9626d, black +connection_error = #f44747, black +unproto_error = #f44747, black +entry_line = light gray, black diff --git a/themes/lightMuted.cfg b/themes/lightMuted.cfg new file mode 100644 index 0000000..e6e33f4 --- /dev/null +++ b/themes/lightMuted.cfg @@ -0,0 +1,26 @@ +# Add the [Theme] section below to paracon.cfg + +[Theme] +menu_key = #3c40f6,bold, #ccd0da +menu_text = #303243, #ccd0da +tabbar_unsel = #9ca0b0, #dce0e8 +tabbar_sel = #4c4f69,bold, #bcc0cc +dropdown_item = #4c4f69, #eff1f5 +dropdown_sel = #eff1f5,bold, #1e66f5 +button_select = #4c4f69, #eff1f5 +button_focus = #eff1f5,bold, #1e66f5 +dialog_back = #4c4f69, #eff1f5 +dialog_header = #1e66f5,bold, #dce0e8 +field_error = #f44747, #eff1f5 +window_norm = #9ca0b0, #eff1f5 +window_sel = #179299, #eff1f5 +monitor_text = #1a1b22, #eff1f5 +monitor_call = #179299, #eff1f5 +monitor_own = #e64553, #eff1f5 +monitor_relayed = #df8e1d, #eff1f5 +monitor_frame = #9ca0b0, #eff1f5 +connection_inbound = #4c4f69, #eff1f5 +connection_outbound = #e64553, #eff1f5 +connection_error = #f44747, #eff1f5 +unproto_error = #f44747, #eff1f5 +entry_line = #1a1b22, #e6e9ef diff --git a/themes/vsCode.cfg b/themes/vsCode.cfg new file mode 100644 index 0000000..b51ecb7 --- /dev/null +++ b/themes/vsCode.cfg @@ -0,0 +1,27 @@ +; VS Code Dark+ inspired theme + +[Theme] +menu_key = #569cd6,bold, #2d2d2d +menu_text = #d4d4d4, #2d2d2d +tabbar_unsel = #858585, #252526 +tabbar_sel = #ffffff,bold, #1e1e1e +dropdown_item = #d4d4d4, #252526 +dropdown_sel = #dcdcaa,bold, #094771 +button_select = #d4d4d4, #3c3c3c +button_focus = #ffffff,bold, #0e639c +dialog_back = #d4d4d4, #252526 +dialog_header = #ffffff,bold, #3c3c3c +field_error = #f44747, #252526 +window_norm = #858585, #1e1e1e +window_sel = #569cd6, #1e1e1e +monitor_text = #d4d4d4, #1e1e1e +monitor_call = #4ec9b0, #1e1e1e +monitor_own = #ce9178, #1e1e1e +monitor_relayed = #dcdcaa, #1e1e1e +monitor_frame = #6a9955, #1e1e1e +connection_inbound = #9cdcfe, #1e1e1e +connection_outbound = #ce9178, #1e1e1e +connection_error = #f44747, #1e1e1e +unproto_error = #f44747, #1e1e1e +entry_line = #d4d4d4, #3c3c3c + From dfb403ca4a7a4f2458c3d170d89efb54124bcadf Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Sat, 18 Apr 2026 13:28:47 -0700 Subject: [PATCH 3/6] Add terminal color support on inbound connections. This supports ANSI 16 color and 24bit colors, and overrides the default inbound text colors (obviously) when color codes are present. As far as I can tell the only way to do this is to do manual parsing of the color codes because the underlying library doesn't support this natively. The end result is not so bad imho. --- paracon/paracon.py | 123 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 3568ecd..720b2fb 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -240,6 +240,116 @@ def _color_info_line(text, own=False, count=0, heard_repeaters=None): return line +# ============================================================================= +# ANSI SGR Parser +# ============================================================================= + +_ANSI_CSI_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m') + +# urwid color names for ANSI indices 0-7 (standard) and 8-15 (bright) +_ANSI_COLORS_16 = [ + 'black', 'dark red', 'dark green', 'brown', + 'dark blue', 'dark magenta', 'dark cyan', 'light gray', + 'dark gray', 'light red', 'light green', 'yellow', + 'light blue', 'light magenta', 'light cyan', 'white', +] + + +def _make_ansi_attr(fg, bg, bold, italics, underline): + """Build a urwid AttrSpec from the current ANSI SGR state.""" + attrs = [] + if bold: + attrs.append('bold') + if italics: + attrs.append('italics') + if underline: + attrs.append('underline') + fg_spec = fg + (',' + ','.join(attrs) if attrs else '') + needs_true = ( + (fg.startswith('#') and len(fg) == 7) + or (bg.startswith('#') and len(bg) == 7) + ) + return urwid.AttrSpec(fg_spec, bg, colors=(2 ** 24 if needs_true else 256)) + + +def _parse_ansi_markup(line): + """Parse ANSI SGR escape sequences and return a urwid markup list. + + Returns a list of (AttrSpec, text) tuples when the line contains any SGR + sequences, or None when there are none (so callers can fall back to + palette-name styling). + """ + if '\x1b' not in line: + return None + markup = [] + fg, bg = 'default', 'default' + bold = italics = underline = False + pos = 0 + for m in _ANSI_CSI_SGR_RE.finditer(line): + segment = line[pos:m.start()] + if segment: + markup.append((_make_ansi_attr(fg, bg, bold, italics, underline), + segment)) + pos = m.end() + params_str = m.group(1) + params = ([int(p) for p in params_str.split(';') if p] + if params_str else [0]) + i = 0 + while i < len(params): + p = params[i] + if p == 0: + fg, bg = 'default', 'default' + bold = italics = underline = False + elif p == 1: + bold = True + elif p == 3: + italics = True + elif p == 4: + underline = True + elif p == 22: + bold = False + elif p == 23: + italics = False + elif p == 24: + underline = False + elif 30 <= p <= 37: + fg = _ANSI_COLORS_16[p - 30] + elif p == 38 and i + 1 < len(params): + mode = params[i + 1] + if mode == 2 and i + 4 < len(params): + fg = '#{:02x}{:02x}{:02x}'.format( + params[i + 2], params[i + 3], params[i + 4]) + i += 4 + elif mode == 5 and i + 2 < len(params): + fg = 'h{:d}'.format(params[i + 2]) + i += 2 + elif p == 39: + fg = 'default' + elif 40 <= p <= 47: + bg = _ANSI_COLORS_16[p - 40] + elif p == 48 and i + 1 < len(params): + mode = params[i + 1] + if mode == 2 and i + 4 < len(params): + bg = '#{:02x}{:02x}{:02x}'.format( + params[i + 2], params[i + 3], params[i + 4]) + i += 4 + elif mode == 5 and i + 2 < len(params): + bg = 'h{:d}'.format(params[i + 2]) + i += 2 + elif p == 49: + bg = 'default' + elif 90 <= p <= 97: + fg = _ANSI_COLORS_16[p - 90 + 8] + elif 100 <= p <= 107: + bg = _ANSI_COLORS_16[p - 100 + 8] + i += 1 + remaining = line[pos:] + if remaining: + markup.append((_make_ansi_attr(fg, bg, bold, italics, underline), + remaining)) + return markup if markup else None + + class MonitorPanel(urwid.WidgetWrap): def __init__(self): self._log = urwidx.LoggingDequeListWalker([]) @@ -552,7 +662,7 @@ def __init__(self, panel_changed_callback): self._decoders = self._init_decoders() self._timer_key = None self._periodic_key = None - self._line_remains = '' + self._line_remains = b'' self._log = urwidx.LoggingDequeListWalker([]) self._list = SizeListBox(self._log) self._menubar = urwidx.MenuBar(self.MenuCommand) @@ -751,16 +861,21 @@ def _gather_lines(self, data): if len(self._line_remains): parts[0] = self._line_remains + parts[0] self._line_remains = b'' - if data[-1] != b'\r': + if data[-1:] != b'\r': self._line_remains = parts[-1] del parts[-1] for part in parts: self.add_line(self._decode_line(part)) def add_line(self, line): - text = urwid.Text(line) if type(line) is str: - text = urwid.AttrMap(text, 'connection_inbound') + markup = _parse_ansi_markup(line) + if markup is not None: + text = urwid.Text(markup) + else: + text = urwid.AttrMap(urwid.Text(line), 'connection_inbound') + else: + text = urwid.Text(line) # Save the state of visibility before appending new content ends_visible = self._list.ends_visible(self._list.size) self._log.append(text) From d864d08cf643ded9da2d0df4784107efd9b10718 Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Sun, 19 Apr 2026 11:10:17 -0700 Subject: [PATCH 4/6] Bugfix: if the remote stations ends its transmission without a CR or LF at the end, the last incoming line was not displayed. This commit fixes that behavior. --- paracon/paracon.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/paracon/paracon.py b/paracon/paracon.py index 720b2fb..b3e4645 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -833,6 +833,9 @@ def _update_from_queue(self, obj): self._gather_lines(data) else: logger.debug('Unknown queue entry: {}'.format(kind)) + if self._line_remains: + self.add_line(self._decode_line(self._line_remains)) + self._line_remains = b'' return result def _decode_line(self, data): From f3a4643903e90461fdb38ae05b37bc0a8ce44de0 Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Sun, 19 Apr 2026 22:04:52 -0700 Subject: [PATCH 5/6] Improvement: when lines arrive across multiple frames, display the data as early as possible without waiting for a newline (CR, LF, etc). This is especially needed in case color escape sequence are broken across frames. --- paracon/paracon.py | 83 ++++++++++++++++++++++++++++++++++++++------ requirements-dev.txt | 1 + 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index b3e4645..95416f0 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -245,6 +245,15 @@ def _color_info_line(text, own=False, count=0, heard_repeaters=None): # ============================================================================= _ANSI_CSI_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m') +# Matches a partial CSI SGR escape at end of bytes (ESC alone, ESC+[, or +# ESC+[params with no closing 'm'), so we can avoid flushing an incomplete +# escape sequence prematurely. +_ANSI_PARTIAL_ESC_RE = re.compile(rb'\x1b(\[([0-9;]*))?$') + +# How long (seconds) to hold a partial escape sequence before giving up and +# flushing it as raw text. Safety net for data loss; under normal operation +# the completing bytes arrive in the very next frame. +_LINE_REMAINS_TIMEOUT = 2.0 # urwid color names for ANSI indices 0-7 (standard) and 8-15 (bright) _ANSI_COLORS_16 = [ @@ -662,6 +671,8 @@ def __init__(self, panel_changed_callback): self._decoders = self._init_decoders() self._timer_key = None self._periodic_key = None + self._line_remains_time = 0.0 + self._partial_widget = None self._line_remains = b'' self._log = urwidx.LoggingDequeListWalker([]) self._list = SizeListBox(self._log) @@ -756,6 +767,8 @@ def _reset(self): app.stop_periodic(self._periodic_key) self._periodic_key = None self._log.set_logfile(None) + self._line_remains = b'' + self._partial_widget = None self._menubar.menu.enable( self.MenuCommand.CONNECT, True) self._menubar.menu.enable( @@ -822,6 +835,8 @@ def _update_from_queue(self, obj): else: message = ('connection_error', 'Connection aborted') + self._line_remains = b'' + self._partial_widget = None self.add_line(message) self._log.set_logfile(None) self._menubar.menu.enable( @@ -834,8 +849,17 @@ def _update_from_queue(self, obj): else: logger.debug('Unknown queue entry: {}'.format(kind)) if self._line_remains: - self.add_line(self._decode_line(self._line_remains)) - self._line_remains = b'' + if _ANSI_PARTIAL_ESC_RE.search(self._line_remains): + # Incomplete escape sequence: hold until the next frame + # completes it. Flush as raw text after the timeout as a + # safety net in case the completing bytes never arrive. + if time.time() - self._line_remains_time > _LINE_REMAINS_TIMEOUT: + self._show_partial(self._decode_line(self._line_remains)) + else: + # Normal unterminated text: show or update in-place now. + # The content stays in _line_remains so the next frame can + # still combine with it; _show_partial will update the row. + self._show_partial(self._decode_line(self._line_remains)) return result def _decode_line(self, data): @@ -861,27 +885,66 @@ def _gather_lines(self, data): # decoded with different decoders. data = data.replace(b'\r\n', b'\r').replace(b'\n', b'\r') parts = data.split(b'\r') - if len(self._line_remains): + had_remains = bool(self._line_remains) + if had_remains: parts[0] = self._line_remains + parts[0] self._line_remains = b'' if data[-1:] != b'\r': self._line_remains = parts[-1] + self._line_remains_time = time.time() del parts[-1] - for part in parts: - self.add_line(self._decode_line(part)) + for i, part in enumerate(parts): + decoded = self._decode_line(part) + if i == 0 and had_remains and self._partial_widget is not None: + # The first CR-terminated line completes what was partially + # shown on screen: update that row in-place. + self._finalize_partial(decoded) + else: + self.add_line(decoded) - def add_line(self, line): + def _make_line_widget(self, line): if type(line) is str: markup = _parse_ansi_markup(line) if markup is not None: - text = urwid.Text(markup) + return urwid.Text(markup) else: - text = urwid.AttrMap(urwid.Text(line), 'connection_inbound') + return urwid.AttrMap(urwid.Text(line), 'connection_inbound') else: - text = urwid.Text(line) + return urwid.Text(line) + + def _show_partial(self, line): + widget = self._make_line_widget(line) + if self._partial_widget is None: + # First time: append a new row and auto-scroll. + ends_visible = self._list.ends_visible(self._list.size) + self._log.append(widget) + if 'bottom' in ends_visible: + self._list.set_focus(len(self._log) - 1, 'above') + else: + # Already on screen as the last row: replace it in-place. + self._log[-1] = widget + self._log._modified() + self._partial_widget = widget + + def _finalize_partial(self, line): + widget = self._make_line_widget(line) + self._log[-1] = widget + self._log._modified() + self._partial_widget = None + + def add_line(self, line): + # Adding a complete line clears any in-progress partial state. + # If a partial row was already on screen (_partial_widget set), also + # clear _line_remains: the displayed fragment is now stranded below a + # new permanent row and can no longer be updated in-place, so keeping + # it would cause a duplicate on the next flush. + if self._partial_widget is not None: + self._line_remains = b'' + self._partial_widget = None + widget = self._make_line_widget(line) # Save the state of visibility before appending new content ends_visible = self._list.ends_visible(self._list.size) - self._log.append(text) + self._log.append(widget) # Auto-scroll only if the last entry is currently visible (i.e. the # user has not scrolled up to view earlier entries) if 'bottom' in ends_visible: diff --git a/requirements-dev.txt b/requirements-dev.txt index afd3565..58e542e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ tox +pytest From 23ef40cf394473fa9107b33324dfa54d65ef7ccf Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Sat, 25 Apr 2026 16:00:28 -0700 Subject: [PATCH 6/6] Bugfix: if a digipeater appears multiple times in a path, make sure that if one instance is heard as repeated (with a "*"), we don't accidentally set all the instances as repeated, only the correct ones. --- paracon/paracon.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 95416f0..488a45b 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -224,10 +224,18 @@ def _color_info_line(text, own=False, count=0, heard_repeaters=None): if m['call_via']: vias = m['call_via'].split(',') line.append(('monitor_text', " Via ")) - for via in vias: + # Find the index of the last via with a '*', which indicates an H-bit-set + # repeater. This is necessary because a repeater may appear multiple times + # and we only want to color up to the ones that are actually repeated. + last_repeated_index = 0 + for index, via in enumerate(vias): + if via.strip().endswith('*'): + last_repeated_index = index + + for index, via in enumerate(vias): via = via.strip() base = via.rstrip('*') - if heard_repeaters and base in heard_repeaters: + if heard_repeaters and base in heard_repeaters and index <= last_repeated_index: line.append(('monitor_relayed', base + '*')) else: line.append((monitor_call, via))