diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 7b4483a..e18663d 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -156,6 +156,7 @@ class NomadNetworkApp: self.rrc_history_per_room_cap = 500 self.rrc_filter_loaded_history = True self.rrc_ephemeral_notices = 600 + self.rrc_nick_colors = True self.rrc_ui_justify_msgs = True self.rrc_ui_space_msgs = False self.rrc_ui_render_markdown = True @@ -944,6 +945,11 @@ class NomadNetworkApp: except Exception: value = False self.rrc_ui_space_msgs = value + if option == "nick_colors": + try: value = self.config["rrc"].as_bool(option) + except Exception: value = True + self.rrc_nick_colors = value + if option == "render_markdown": try: value = self.config["rrc"].as_bool(option) except Exception: value = True @@ -1296,6 +1302,7 @@ ephemeral_notices = 10 # Other display and formatting options: render_markdown = yes render_micron = yes +nick_colors = yes justify_msgs = yes space_msgs = no diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index e7e72df..07e33fb 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -13,7 +13,8 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps -from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags +from nomadnet.util import sanitize_name, strip_modifiers, strip_micron +from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags theme_dark = { "text": "ddd", @@ -24,7 +25,10 @@ theme_dark = { "text": "ddd", "error": "f55", "system": "888", "mention": "fb4", - "link": "79d", } + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 1.4 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 + "nick_colors": ["f68787", "00c394", "d59e00", "62be00", "a1ac76", "95b600", "76a9ee", "81b385", "7eb1a1", "e89264", "7cb0b0", "00c0c0", "8cacbb", "32b4db", "98a8c3", "bbab00", "95a0fd", "a9a2ca", "ad98fe", "c58ffa", "df83f4", "c49abf", "f380c7", "f484a7"], + } theme_light = { "text": "111", "ts": "888", @@ -34,7 +38,10 @@ theme_light = { "text": "111", "error": "a22", "system": "888", "mention": "c50", - "link": "79d", } + "link": "79d", + # colorgen.py --hue-step 18 --sat-start 25 --sat-steps 2 --sat-step 100 --light-step 30 --normalize --normalize-target 2.5 --perceptual-multiplier 0.2 --discard 1,5,7,11,13,14,17,27,31,33,37,39,3,16,18,36 > ~/.nomadnetwork/storage/pages/index.mu + "nick_colors": ["ca0000", "008000", "9d1c00", "007800", "2c5200", "006800", "004ac0", "006100", "005d2c", "b70000", "005b5b", "007b7a", "005071", "0064a5", "004580", "714f00", "0026d3", "48318c", "5200d5", "8400cf", "aa00c8", "820079", "c60086", "c80043"], + } class _ChatLinkableText(LinkableText): @@ -451,6 +458,7 @@ class RoomWidget(urwid.WidgetWrap): rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))] for name, is_self in names: + name = sanitize_name(name); name = name[:15]+"…" if len(name) > 16 else name if is_self: rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted")) else: @@ -901,7 +909,14 @@ class _ChatLinkDelegate: except Exception as e: RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR) +def get_nick_color(sender_hash, theme, shift=15): + if type(sender_hash) == str: + try: sender_hash = sender_hash.encode("utf-8") + except: pass + if not type(sender_hash) == bytes: return theme["nick_peer"] + return theme["nick_colors"][(int.from_bytes(sender_hash)+shift)%len(theme["nick_colors"])] +room_nick_src_cache = {} mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) def _message_widget(app, hub, m, link_delegate=None): t = theme_dark if app.config["textui"]["theme"] == nomadnet.ui.TextUI.THEME_DARK else theme_light @@ -944,12 +959,12 @@ def _message_widget(app, hub, m, link_delegate=None): except Exception: pass - if m.nick: - sender = m.nick - elif isinstance(m.src, (bytes, bytearray)): - sender = _short_hash(m.src) - else: - sender = "?" + if m.nick: sender = sanitize_name(m.nick) + elif isinstance(m.src, (bytes, bytearray)): sender = _short_hash(m.src) + else: sender = "?" + + if isinstance(m.src, (bytes, bytearray)): + room_nick_src_cache[m.nick] = m.src nick_attr = "irc_nick_self" if own else "irc_nick_peer" body = m.text or "" @@ -960,7 +975,12 @@ def _message_widget(app, hub, m, link_delegate=None): for span in spans: ms = span[0] mb = span[1] - if ms.startswith("irc_mention"): message_body += f"`!`F{t['mention']}{mb}`f`!" + if ms.startswith("irc_mention"): + if not app.rrc_nick_colors: message_body += f"`!`F{t['mention']}{mb}`f`!" + else: + try: message_body += f"`!`FT{get_nick_color(room_nick_src_cache[m.nick], t)}{mb}`f`!" + except: message_body += f"`!`F{t['mention']}{mb}`f`!" + elif ms.startswith("link_"): kind = ms[len("link_"):] label = mb.split("`")[0] @@ -978,7 +998,8 @@ def _message_widget(app, hub, m, link_delegate=None): mbo = mdc.format_block(strip_escaped_micron(mb)) if app.rrc_ui_render_markdown else strip_escaped_micron(mb) message_body += strip_non_formatting_tags(mbo) - nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" + if app.rrc_nick_colors: nick_attr = f"`FT{get_nick_color(m.src, t)}" + else: nick_attr = f"`F{t['nick_self']}" if own else f"`F{t['nick_peer']}" irc_ts = f"`F{t['ts']}" prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}" diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 7a8e05a..5953e2b 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -811,6 +811,12 @@ Whether or not to render markdown formatting in messages. Whether or not to render micron formatting in messages. When using micron in messages, use the ¦ character in place of backticks. < +>>> +`!nick_colors = yes`! +>>>> +Whether or not to render RRC nicks in distinct colors based on identity hash. +< + >>> `!justify_msgs = yes`! `!space_msgs = no`! diff --git a/tools/colorgen.py b/tools/colorgen.py new file mode 100644 index 0000000..0ff636a --- /dev/null +++ b/tools/colorgen.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Color Table Generator with Perceptual Normalization + +Generates HSL color tables with optional normalization to uniform perceptual +intensity (CIELAB L*) followed by global lightness scaling. +""" + +import argparse +import colorsys +import math +import sys + + +def hsl_to_rgb(h, s, l): + """Convert HSL (0-360, 0-100, 0-100) to RGB (0.0-1.0).""" + h_norm = (h % 360) / 360.0 + s_norm = max(0, min(100, s)) / 100.0 + l_norm = max(0, min(100, l)) / 100.0 + r, g, b = colorsys.hls_to_rgb(h_norm, l_norm, s_norm) + return (r, g, b) + + +def rgb_to_hex(r, g, b): + """Convert RGB 0.0-1.0 to lowercase hex string.""" + r = max(0, min(1, r)) + g = max(0, min(1, g)) + b = max(0, min(1, b)) + return f"{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + + +# CIELAB conversion functions +def srgb_to_linear(c): + if c <= 0.04045: + return c / 12.92 + return ((c + 0.055) / 1.055) ** 2.4 + + +def linear_to_srgb(c): + if c <= 0.0031308: + return c * 12.92 + return 1.055 * (c ** (1/2.4)) - 0.055 + + +def rgb_to_lab(r, g, b): + """Convert sRGB (0-1) to CIELAB (L: 0-100, a,b: ~-128 to 127). D65 illuminant.""" + # sRGB to Linear + r_lin = srgb_to_linear(r) + g_lin = srgb_to_linear(g) + b_lin = srgb_to_linear(b) + + # Linear to XYZ (D65) + X = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin + Y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin + Z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin + + # XYZ to LAB + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def f(t): + if t > delta**3: + return t ** (1/3) + return t / (3 * delta**2) + 4/29 + + L = 116 * f(Y / Yn) - 16 + a = 500 * (f(X / Xn) - f(Y / Yn)) + b_val = 200 * (f(Y / Yn) - f(Z / Zn)) + + return (L, a, b_val) + + +def lab_to_rgb(L, a, b_val): + """Convert CIELAB to sRGB (0-1).""" + Xn, Yn, Zn = 95.047, 100.0, 108.883 + delta = 6/29 + + def inv_f(y): + if y > delta: + return y ** 3 + return 3 * delta**2 * (y - 4/29) + + L_adj = (L + 16) / 116 + X = Xn * inv_f(L_adj + a / 500) + Y = Yn * inv_f(L_adj) + Z = Zn * inv_f(L_adj - b_val / 200) + + # XYZ to Linear RGB + r_lin = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z + g_lin = -0.9692660 * X + 1.8760108 * Y + 0.0415560 * Z + b_lin = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z + + r = linear_to_srgb(r_lin) + g = linear_to_srgb(g_lin) + b = linear_to_srgb(b_lin) + + return (r, g, b) + + +def generate_colors( + hues=None, + hue_start=0.0, + hue_step=30.0, + sat_start=70.0, + sat_step=0.0, + sat_steps=1, + light_start=50.0, + light_step=0.0, + light_steps=1, + perceptual_multiplier=1.0, + use_hsl_space=False, + normalize=False, + normalize_target=None, + discards=[] +): + """ + Generate color table with optional perceptual normalization. + + Pipeline: + 1. Generate HSL variations + 2. If normalize: Convert to LAB, set all L* to target (mean or specified) + 3. Apply global perceptual multiplier to L* + 4. Convert to RGB and output hex + """ + # Determine hue list + if hues is not None: + hue_list = [float(h) % 360 for h in hues] + else: + hue_list = [] + current = hue_start % 360 + if hue_step <= 0: + raise ValueError("hue_step must be positive when using start/step mode") + while current < 360: + hue_list.append(current) + current += hue_step + + # Determine if we need LAB processing + needs_lab = normalize or (perceptual_multiplier != 1.0) or not use_hsl_space + + colors = [] + lab_colors = [] # Store as (L, a, b) tuples if processing needed + + for h in hue_list: + for i in range(sat_steps): + s = sat_start + (i * sat_step) + s = max(0, min(100, s)) + + for j in range(light_steps): + # l = light_start + (j * light_step) + l = light_start + (i * light_step) + l = max(0, min(100, l)) + + r, g, b = hsl_to_rgb(h, s, l) + + if not needs_lab: + colors.append(rgb_to_hex(r, g, b)) + else: + L, a, b_lab = rgb_to_lab(r, g, b) + lab_colors.append((L, a, b_lab)) + + filtered_colors = [] + filtered_lab_colors = [] + i = 0 + for c in colors: + i += 1 + if not str(i) in discards: + filtered_colors.append(c) + + i = 0 + for c in lab_colors: + i += 1 + if not str(i) in discards: + filtered_lab_colors.append(c) + + colors = filtered_colors + lab_colors = filtered_lab_colors + + if not needs_lab: + return colors + + # Step 2: Perceptual Normalization (equalize intensity) + if normalize: + if normalize_target is not None: + target_L = normalize_target + else: + # Calculate mean perceptual lightness + target_L = sum(lab[0] for lab in lab_colors) / len(lab_colors) + print(f"Target L*: {target_L}") + + # Clamp target to valid LAB range + target_L = max(0, min(100, target_L)) + + # Normalize all colors to target L, preserving hue (a, b) + lab_colors = [(target_L, a, b) for (L, a, b) in lab_colors] + + # Step 3: Apply final global perceptual multiplier + final_colors = [] + for L, a, b in lab_colors: + L_final = L * perceptual_multiplier + L_final = max(0, min(100, L_final)) + r, g, b = lab_to_rgb(L_final, a, b) + final_colors.append(rgb_to_hex(r, g, b)) + + return final_colors + + +def main(): + parser = argparse.ArgumentParser( + description="Generate perceptually normalized color tables", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Pipeline: HSL Generation → [Normalize to uniform L*] → Global Multiplier → Hex + +Examples: + # 32 colors normalized to same perceptual intensity, then darkened 20% + python3 colorgen.py --hue-start 0 --hue-step 11.25 --sat-start 75 --normalize --perceptual-multiplier 0.8 + + # Force all colors to exactly L*=55, then scale by 1.1 + python3 colorgen.py --hues "0,90,180,270" --normalize --normalize-target 55 --perceptual-multiplier 1.1 + + # Generate with lightness variation, normalize to mean intensity, output brightened + python3 colorgen.py --hue-start 15 --hue-step 30 --light-start 40 --light-step 10 --light-steps 3 --normalize --perceptual-multiplier 1.2 + """ + ) + + # Hue configuration + hue_group = parser.add_mutually_exclusive_group() + hue_group.add_argument("--hues", type=str, metavar="LIST", + help='Comma-separated hue values, e.g., "0,60,120"') + hue_group.add_argument("--hue-start", type=float, default=0.0, + help="Initial hue offset in degrees") + parser.add_argument("--hue-step", type=float, default=30.0, + help="Separation between hues") + + # Saturation/Lightness configuration + parser.add_argument("--sat-start", type=float, default=75.0, + help="Initial saturation (0-100)") + parser.add_argument("--sat-step", type=float, default=0.0, + help="Saturation step") + parser.add_argument("--sat-steps", type=int, default=1, + help="Number of saturation variations") + parser.add_argument("--light-start", type=float, default=50.0, + help="Initial lightness (0-100)") + parser.add_argument("--light-step", type=float, default=0.0, + help="Lightness step") + parser.add_argument("--light-steps", type=int, default=1, + help="Number of lightness variations") + + # Perceptual processing + parser.add_argument("--normalize", action="store_true", + help="Normalize all colors to same perceptual intensity (L*) before applying multiplier") + parser.add_argument("--normalize-target", type=float, default=None, metavar="L", + help="Target L* value (0-100) for normalization. Default: mean of generated colors") + parser.add_argument("--perceptual-multiplier", type=float, default=1.0, + help="Final global lightness multiplier (1.0=no change)") + parser.add_argument("--hsl-space", action="store_true", + help="Skip LAB conversion if not normalizing (faster, less accurate perceptually)") + + # Output + parser.add_argument("--python-list", action="store_true", + help="Output as Python list") + parser.add_argument("--separator", type=str, default="\n", + help="Separator between colors") + parser.add_argument("--stats", action="store_true", + help="Print generation stats to stderr") + hue_group.add_argument("--discard", type=str, metavar="LIST", + help='Discard output indexes, e.g., "20,2,33"') + + args = parser.parse_args() + + if args.hsl_space and args.normalize: + print("Warning: --hsl-space ignored because --normalize requires LAB space", file=sys.stderr) + args.hsl_space = False + + try: + if args.discard: discards = args.discard.split(",") + else: discards = [] + + colors = generate_colors( + hues=args.hues.split(",") if args.hues else None, + hue_start=args.hue_start, + hue_step=args.hue_step, + sat_start=args.sat_start, + sat_step=args.sat_step, + sat_steps=args.sat_steps, + light_start=args.light_start, + light_step=args.light_step, + light_steps=args.light_steps, + perceptual_multiplier=args.perceptual_multiplier, + use_hsl_space=args.hsl_space, + normalize=args.normalize, + normalize_target=args.normalize_target, + discards=discards, + ) + + i = 0 + # Enable to test on light background + # print(f"#!bg=fff\n") + if args.python_list: print("colors = [", end="") + for c in colors: + i+=1 + if args.python_list: + print(f"\"{c}\", ", end="") + + else: print(f"`FT{c}colored {i}`f") + + if args.python_list: print("]") + + if args.stats: + total = len(colors) + mode = "normalized + " if args.normalize else "" + print(f"# Generated {total} colors ({mode}L* × {args.perceptual_multiplier})", + file=sys.stderr) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file