#include "tui.h" #include "client.h" #include "ssh_server.h" #include "chat_room.h" #include "utf8.h" #include static bool is_join_leave_msg(const message_t *msg) { if (strcmp(msg->username, "系统") != 0) return false; return strstr(msg->content, "加入了聊天室") != NULL || strstr(msg->content, "离开了聊天室") != NULL; } static const char *username_color(const char *name) { static const char *colors[] = { "\033[31m", "\033[32m", "\033[33m", "\033[34m", "\033[35m", "\033[36m", }; unsigned int h = 5381; for (const char *p = name; *p; p++) h = h * 33 + (unsigned char)*p; return colors[h % 6]; } static void format_message_colored(const message_t *msg, char *buffer, size_t buf_size, int width, const char *my_username) { struct tm tm_info; localtime_r(&msg->timestamp, &tm_info); char time_str[32]; strftime(time_str, sizeof(time_str), "%H:%M", &tm_info); bool mentioned = false; if (my_username && my_username[0] != '\0' && strcmp(msg->username, "系统") != 0) { char mention[MAX_USERNAME_LEN + 2]; snprintf(mention, sizeof(mention), "@%s", my_username); if (strstr(msg->content, mention) != NULL) { mentioned = true; } } const char *hl_start = mentioned ? "\033[1;33m" : ""; const char *hl_end = mentioned ? "\033[0m" : ""; if (strcmp(msg->username, "系统") == 0) { snprintf(buffer, buf_size, "\033[90m--> %s\033[0m", msg->content); } else if (strcmp(msg->username, "*") == 0) { snprintf(buffer, buf_size, "\033[90m%s\033[0m \033[3;36m* %s\033[0m", time_str, msg->content); } else { snprintf(buffer, buf_size, "\033[90m%s\033[0m %s%s\033[0m: %s%s%s", time_str, username_color(msg->username), msg->username, hl_start, msg->content, hl_end); } /* Plain-text version for width calculation */ char plain[MAX_MESSAGE_LEN + 128]; if (strcmp(msg->username, "系统") == 0) { snprintf(plain, sizeof(plain), "--> %s", msg->content); } else if (strcmp(msg->username, "*") == 0) { snprintf(plain, sizeof(plain), "%s * %s", time_str, msg->content); } else { snprintf(plain, sizeof(plain), "%s %s: %s", time_str, msg->username, msg->content); } if (utf8_string_width(plain) > width) { /* Rebuild with truncated content */ int prefix_width; char prefix_plain[256]; if (strcmp(msg->username, "系统") == 0) { snprintf(prefix_plain, sizeof(prefix_plain), "--> "); } else if (strcmp(msg->username, "*") == 0) { snprintf(prefix_plain, sizeof(prefix_plain), "%s * ", time_str); } else { snprintf(prefix_plain, sizeof(prefix_plain), "%s %s: ", time_str, msg->username); } prefix_width = utf8_string_width(prefix_plain); int content_width = width - prefix_width; if (content_width < 4) content_width = 4; char truncated_content[MAX_MESSAGE_LEN]; if (strcmp(msg->username, "系统") == 0) { strncpy(truncated_content, msg->content, sizeof(truncated_content) - 1); truncated_content[sizeof(truncated_content) - 1] = '\0'; } else if (strcmp(msg->username, "*") == 0) { snprintf(truncated_content, sizeof(truncated_content), "* %s", msg->content); } else { strncpy(truncated_content, msg->content, sizeof(truncated_content) - 1); truncated_content[sizeof(truncated_content) - 1] = '\0'; } utf8_truncate(truncated_content, content_width); if (strcmp(msg->username, "系统") == 0) { snprintf(buffer, buf_size, "\033[90m--> %s\033[0m", truncated_content); } else if (strcmp(msg->username, "*") == 0) { snprintf(buffer, buf_size, "\033[90m%s\033[0m \033[3;36m%s\033[0m", time_str, truncated_content); } else { snprintf(buffer, buf_size, "\033[90m%s\033[0m %s%s\033[0m: %s%s%s", time_str, username_color(msg->username), msg->username, hl_start, truncated_content, hl_end); } } } /* Clear the screen */ void tui_clear_screen(client_t *client) { if (!client || !client->connected) return; const char *clear = ANSI_CLEAR ANSI_HOME; client_send(client, clear, strlen(clear)); } /* Render the pre-login welcome banner. * * Centred horizontally; vertically positioned about a third of the way down * the available height so the user's eye lands naturally on it before the * prompt below. Uses light box-drawing characters (U+256D / U+2570) so the * frame matches the rest of the TUI's aesthetic instead of the older ASCII * `==` rules. */ void tui_render_welcome(client_t *client) { if (!client || !client->connected) return; int rw = client->width; int rh = client->height; if (rw < 10) rw = 10; if (rh < 4) rh = 4; /* Lines, in display order. Width is computed in display columns. */ const char *line1 = "TNT · " TNT_VERSION; const char *line2 = "匿名聊天室 · SSH"; const char *line3 = "Anonymous chat over SSH"; int inner_w = utf8_string_width(line1); int w2 = utf8_string_width(line2); int w3 = utf8_string_width(line3); if (w2 > inner_w) inner_w = w2; if (w3 > inner_w) inner_w = w3; inner_w += 4; /* 2 columns padding on each side */ /* Fall back to plain prompt if the terminal is too narrow for the frame. */ if (inner_w + 2 > rw) { char fallback[128]; int n = snprintf(fallback, sizeof(fallback), ANSI_CLEAR ANSI_HOME "TNT %s — anonymous chat over SSH\r\n\r\n", TNT_VERSION); if (n > 0) client_send(client, fallback, (size_t)n); return; } int top_pad = rh / 3; if (top_pad < 1) top_pad = 1; int left_pad = (rw - (inner_w + 2)) / 2; if (left_pad < 0) left_pad = 0; /* ~5 KiB is plenty for the framed banner even on the largest terminals. */ char buf[4096]; size_t pos = 0; buffer_appendf(buf, sizeof(buf), &pos, ANSI_CLEAR ANSI_HOME); for (int i = 0; i < top_pad; i++) { buffer_appendf(buf, sizeof(buf), &pos, "\r\n"); } /* Top border: ╭───…───╮ */ for (int i = 0; i < left_pad; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5); buffer_append_bytes(buf, sizeof(buf), &pos, "╭", strlen("╭")); for (int i = 0; i < inner_w; i++) buffer_append_bytes(buf, sizeof(buf), &pos, "─", strlen("─")); buffer_append_bytes(buf, sizeof(buf), &pos, "╮", strlen("╮")); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4); buffer_appendf(buf, sizeof(buf), &pos, "\r\n"); /* Three content lines with surrounding │ borders, centred inside the frame. */ const char *lines[3] = {line1, line2, line3}; int widths[3] = {utf8_string_width(line1), w2, w3}; const char *line_color[3] = {"\033[1;36m", "\033[0m", "\033[2;37m"}; for (int li = 0; li < 3; li++) { for (int i = 0; i < left_pad; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5); buffer_append_bytes(buf, sizeof(buf), &pos, "│", strlen("│")); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4); int pad_total = inner_w - widths[li]; int pad_left = pad_total / 2; int pad_right = pad_total - pad_left; for (int i = 0; i < pad_left; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1); buffer_appendf(buf, sizeof(buf), &pos, "%s%s\033[0m", line_color[li], lines[li]); for (int i = 0; i < pad_right; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5); buffer_append_bytes(buf, sizeof(buf), &pos, "│", strlen("│")); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4); buffer_appendf(buf, sizeof(buf), &pos, "\r\n"); } /* Bottom border: ╰───…───╯ */ for (int i = 0; i < left_pad; i++) buffer_append_bytes(buf, sizeof(buf), &pos, " ", 1); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[36m", 5); buffer_append_bytes(buf, sizeof(buf), &pos, "╰", strlen("╰")); for (int i = 0; i < inner_w; i++) buffer_append_bytes(buf, sizeof(buf), &pos, "─", strlen("─")); buffer_append_bytes(buf, sizeof(buf), &pos, "╯", strlen("╯")); buffer_append_bytes(buf, sizeof(buf), &pos, "\033[0m", 4); buffer_appendf(buf, sizeof(buf), &pos, "\r\n\r\n"); client_send(client, buf, pos); } /* Render the main screen */ void tui_render_screen(client_t *client) { if (!client || !client->connected) return; int render_width = client->width; int render_height = client->height; if (render_width < 10) render_width = 10; if (render_height < 4) render_height = 4; const size_t buf_size = (size_t)(render_height + 10) * (MAX_MESSAGE_LEN + 64) + 2048; char *buffer = malloc(buf_size); if (!buffer) return; size_t pos = 0; buffer[0] = '\0'; /* First pass under lock: compute indices and counts */ pthread_rwlock_rdlock(&g_room->lock); int online = g_room->client_count; int msg_count = g_room->message_count; pthread_rwlock_unlock(&g_room->lock); /* Calculate which messages to show */ int msg_height = render_height - 3; if (msg_height < 1) msg_height = 1; int start = 0; if (client->mode == MODE_NORMAL) { start = client->scroll_pos; if (start > msg_count - msg_height) { start = msg_count - msg_height; } if (start < 0) start = 0; } else { /* INSERT mode: show latest */ if (msg_count > msg_height) { start = msg_count - msg_height; } } int end = start + msg_height; if (end > msg_count) end = msg_count; /* Allocate snapshot outside the lock to avoid blocking writers */ message_t *msg_snapshot = NULL; int snapshot_count = end - start; if (snapshot_count > 0) { msg_snapshot = calloc(snapshot_count, sizeof(message_t)); } /* Second pass under lock: copy messages */ if (msg_snapshot) { pthread_rwlock_rdlock(&g_room->lock); /* Re-clamp in case msg_count changed */ int actual_count = g_room->message_count; int actual_end = (end <= actual_count) ? end : actual_count; int actual_start = (start < actual_end) ? start : actual_end; int actual_snapshot = actual_end - actual_start; if (actual_snapshot > 0 && actual_snapshot <= snapshot_count) { memcpy(msg_snapshot, &g_room->messages[actual_start], actual_snapshot * sizeof(message_t)); snapshot_count = actual_snapshot; } else { snapshot_count = 0; } pthread_rwlock_unlock(&g_room->lock); } /* Now render using snapshot (no lock held) */ /* If mute_joins is set, remove join/leave messages from snapshot in place */ if (client->mute_joins && msg_snapshot) { int filtered = 0; for (int i = 0; i < snapshot_count; i++) { if (!is_join_leave_msg(&msg_snapshot[i])) { msg_snapshot[filtered++] = msg_snapshot[i]; } } snapshot_count = filtered; } /* Move to top (Home) - Do NOT clear screen to prevent flicker */ buffer_appendf(buffer, buf_size, &pos, ANSI_HOME); /* Title bar — segmented chips on a single line, no full-line reverse. * * Segments (left to right): * • bold username * • online count * • mode name (colour matches the mode itself: cyan/yellow/magenta) * • mute marker, only when active * • right-aligned hint * * `· ` separators are dim grey so the eye groups segments without * mistaking them for content. */ struct title_chip { const char *value; const char *value_color; }; struct title_chip chips[3]; int chip_count = 0; chips[chip_count].value = client->username; chips[chip_count].value_color = "\033[1;37m"; chip_count++; char online_buf[32]; snprintf(online_buf, sizeof(online_buf), "在线 %d", online); chips[chip_count].value = online_buf; chips[chip_count].value_color = "\033[37m"; chip_count++; const char *mode_str; const char *mode_color; switch (client->mode) { case MODE_INSERT: mode_str = "INSERT"; mode_color = "\033[36m"; break; case MODE_NORMAL: mode_str = "NORMAL"; mode_color = "\033[33m"; break; case MODE_COMMAND: mode_str = "COMMAND"; mode_color = "\033[35m"; break; default: mode_str = "HELP"; mode_color = "\033[34m"; break; } chips[chip_count].value = mode_str; chips[chip_count].value_color = mode_color; chip_count++; /* Compose left half. */ char left[256]; size_t lpos = 0; int left_width = 0; for (int i = 0; i < chip_count; i++) { if (i > 0) { buffer_appendf(left, sizeof(left), &lpos, "\033[2;37m · \033[0m"); left_width += 3; } buffer_appendf(left, sizeof(left), &lpos, "%s%s\033[0m", chips[i].value_color, chips[i].value); left_width += utf8_string_width(chips[i].value); } if (client->mute_joins) { buffer_appendf(left, sizeof(left), &lpos, " \033[2;37m静音\033[0m"); left_width += 4; } const char *hint = "? 帮助"; int hint_width = utf8_string_width(hint); int gap = render_width - left_width - hint_width - 2; if (gap < 1) gap = 1; buffer_appendf(buffer, buf_size, &pos, " %s", left); for (int i = 0; i < gap; i++) { buffer_append_bytes(buffer, buf_size, &pos, " ", 1); } buffer_appendf(buffer, buf_size, &pos, "\033[2;37m%s\033[0m \033[K\r\n", hint); /* Render messages from snapshot */ if (msg_snapshot) { for (int i = 0; i < snapshot_count; i++) { char msg_line[2048]; format_message_colored(&msg_snapshot[i], msg_line, sizeof(msg_line), render_width, client->username); buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line); } free(msg_snapshot); } /* Fill empty lines and clear them */ for (int i = snapshot_count; i < msg_height; i++) { buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n"); } /* Separator - use box drawing character */ for (int i = 0; i < render_width; i++) { buffer_append_bytes(buffer, buf_size, &pos, "─", strlen("─")); } buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n"); /* Status/Input line */ if (client->mode == MODE_INSERT) { buffer_appendf(buffer, buf_size, &pos, "\033[2;37m›\033[0m \033[K"); } else if (client->mode == MODE_NORMAL) { int total = msg_count; int scroll_pos = client->scroll_pos + 1; if (total == 0) scroll_pos = 0; int unseen = msg_count - end; /* mode reverse-video chip + dim position + optional unseen marker */ if (unseen > 0) { buffer_appendf(buffer, buf_size, &pos, "\033[7;33m NORMAL \033[0m" " \033[2;37m%d / %d\033[0m" " \033[33m▼ %d new\033[0m\033[K", scroll_pos, total, unseen); } else { buffer_appendf(buffer, buf_size, &pos, "\033[7;33m NORMAL \033[0m" " \033[2;37m%d / %d\033[0m\033[K", scroll_pos, total); } } else if (client->mode == MODE_COMMAND) { buffer_appendf(buffer, buf_size, &pos, "\033[35m:\033[0m%s\033[K", client->command_input); } client_send(client, buffer, pos); free(buffer); } /* Render the input line */ void tui_render_input(client_t *client, const char *input) { if (!client || !client->connected) return; int rw = client->width; int rh = client->height; if (rw < 10) rw = 10; if (rh < 4) rh = 4; char buffer[2048]; int input_width = utf8_string_width(input); int avail = rw - 3; if (avail < 1) avail = 1; /* Truncate from start if too long */ char display[MAX_MESSAGE_LEN]; strncpy(display, input, sizeof(display) - 1); display[sizeof(display) - 1] = '\0'; if (input_width > avail) { int excess = input_width - avail; int skip_width = 0; const char *p = input; int bytes_read; while (*p && skip_width < excess) { uint32_t cp = utf8_decode(p, &bytes_read); skip_width += utf8_char_width(cp); p += bytes_read; } strncpy(display, p, sizeof(display) - 1); } /* Move to input line and clear it, then write input */ snprintf(buffer, sizeof(buffer), "\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m›\033[0m %s", rh, display); client_send(client, buffer, strlen(buffer)); } /* Render the command output screen */ void tui_render_command_output(client_t *client) { if (!client || !client->connected) return; int rw = client->width; int rh = client->height; if (rw < 10) rw = 10; if (rh < 4) rh = 4; char buffer[4096]; size_t pos = 0; buffer[0] = '\0'; /* Clear screen */ buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME); /* Title */ const char *title = " COMMAND OUTPUT "; int title_width = strlen(title); int padding = rw - title_width; if (padding < 0) padding = 0; buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title); for (int i = 0; i < padding; i++) { buffer_append_bytes(buffer, sizeof(buffer), &pos, " ", 1); } buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n"); /* Command output - use a copy to avoid strtok corruption */ char output_copy[2048]; strncpy(output_copy, client->command_output, sizeof(output_copy) - 1); output_copy[sizeof(output_copy) - 1] = '\0'; char *line = strtok(output_copy, "\n"); int line_count = 0; int max_lines = rh - 2; while (line && line_count < max_lines) { char truncated[1024]; strncpy(truncated, line, sizeof(truncated) - 1); truncated[sizeof(truncated) - 1] = '\0'; if (utf8_string_width(truncated) > rw) { utf8_truncate(truncated, rw); } buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated); line = strtok(NULL, "\n"); line_count++; } client_send(client, buffer, pos); } /* Get help text based on language */ const char* tui_get_help_text(help_lang_t lang) { if (lang == LANG_EN) { return "TERMINAL CHAT ROOM - HELP\n" "\n" "OPERATING MODES:\n" " INSERT - Type and send messages (default)\n" " NORMAL - Browse message history\n" " COMMAND - Execute commands\n" "\n" "INSERT MODE KEYS:\n" " ESC - Enter NORMAL mode\n" " Enter - Send message\n" " Backspace - Delete character\n" " Ctrl+W - Delete last word\n" " Ctrl+U - Delete line\n" " Ctrl+C - Enter NORMAL mode\n" "\n" "NORMAL MODE KEYS:\n" " i - Return to INSERT mode\n" " : - Enter COMMAND mode\n" " j/k - Scroll down/up one line\n" " Ctrl+D/U - Scroll half page down/up\n" " Ctrl+F/B - Scroll full page down/up\n" " g/G - Jump to top/bottom\n" " ? - Show this help\n" " Ctrl+C - Exit chat\n" "\n" "AVAILABLE COMMANDS:\n" " :list, :users - Show online users\n" " :nick - Change nickname\n" " :msg - Whisper to user\n" " :w - Short alias for :msg\n" " :last [N] - Show last N messages (max 50)\n" " :search - Search message history\n" " :mute-joins - Toggle join/leave notices\n" " :help - Show available commands\n" " :clear - Clear command output\n" " :q, :quit, :exit - Disconnect\n" "\n" "SPECIAL MESSAGES:\n" " /me - Send action (e.g. /me waves)\n" " @username - Mention user (bell + highlight)\n" "\n" "HELP SCREEN KEYS:\n" " q, ESC - Close help\n" " j/k - Scroll down/up\n" " g/G - Jump to top/bottom\n" " e/z - Switch English/Chinese\n"; } else { return "终端聊天室 - 帮助\n" "\n" "操作模式:\n" " INSERT - 输入和发送消息(默认)\n" " NORMAL - 浏览消息历史\n" " COMMAND - 执行命令\n" "\n" "INSERT 模式按键:\n" " ESC - 进入 NORMAL 模式\n" " Enter - 发送消息\n" " Backspace - 删除字符\n" " Ctrl+W - 删除上个单词\n" " Ctrl+U - 删除整行\n" " Ctrl+C - 进入 NORMAL 模式\n" "\n" "NORMAL 模式按键:\n" " i - 返回 INSERT 模式\n" " : - 进入 COMMAND 模式\n" " j/k - 向下/上滚动一行\n" " Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+F/B - 向下/上滚动整页\n" " g/G - 跳到顶部/底部\n" " ? - 显示此帮助\n" " Ctrl+C - 退出聊天\n" "\n" "可用命令:\n" " :list, :users - 显示在线用户\n" " :nick <名字> - 更改昵称\n" " :msg <用户> <文本> - 私聊\n" " :w <用户> <文本> - :msg 的简写\n" " :last [N] - 显示最后 N 条消息(最多50)\n" " :search <关键词> - 搜索消息历史\n" " :mute-joins - 切换加入/离开提示\n" " :help - 显示可用命令\n" " :clear - 清空命令输出\n" " :q, :quit, :exit - 断开连接\n" "\n" "特殊消息:\n" " /me <动作> - 发送动作 (如 /me 挥手)\n" " @用户名 - 提及用户 (响铃+高亮)\n" "\n" "帮助界面按键:\n" " q, ESC - 关闭帮助\n" " j/k - 向下/上滚动\n" " g/G - 跳到顶部/底部\n" " e/z - 切换英文/中文\n"; } } /* Render the help screen */ void tui_render_help(client_t *client) { if (!client || !client->connected) return; int rw = client->width; int rh = client->height; if (rw < 10) rw = 10; if (rh < 4) rh = 4; char buffer[8192]; size_t pos = 0; buffer[0] = '\0'; /* Clear screen */ buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME); /* Title */ const char *title = " HELP "; int title_width = strlen(title); int padding = rw - title_width; if (padding < 0) padding = 0; buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title); for (int i = 0; i < padding; i++) { buffer_append_bytes(buffer, sizeof(buffer), &pos, " ", 1); } buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n"); /* Help content */ const char *help_text = tui_get_help_text(client->help_lang); char help_copy[8192]; strncpy(help_copy, help_text, sizeof(help_copy) - 1); help_copy[sizeof(help_copy) - 1] = '\0'; /* Split into lines and display with scrolling */ char *lines[100]; int line_count = 0; char *line = strtok(help_copy, "\n"); while (line && line_count < 100) { lines[line_count++] = line; line = strtok(NULL, "\n"); } int content_height = rh - 2; if (content_height < 1) content_height = 1; int max_scroll = line_count - content_height + 1; if (max_scroll < 0) max_scroll = 0; int start = client->help_scroll_pos; if (start > max_scroll) start = max_scroll; int end = start + content_height - 1; if (end > line_count) end = line_count; for (int i = start; i < end && (i - start) < content_height - 1; i++) { buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", lines[i]); } /* Fill remaining lines */ for (int i = end - start; i < content_height - 1; i++) { buffer_append_bytes(buffer, sizeof(buffer), &pos, "\r\n", 2); } /* Status line */ buffer_appendf(buffer, sizeof(buffer), &pos, "-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close", start + 1, max_scroll + 1); client_send(client, buffer, pos); }