mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
843 lines
32 KiB
C
843 lines
32 KiB
C
#include "tui.h"
|
||
#include "client.h"
|
||
#include "ssh_server.h"
|
||
#include "chat_room.h"
|
||
#include "help_text.h"
|
||
#include "history_view.h"
|
||
#include "i18n.h"
|
||
#include "system_message.h"
|
||
#include "tui_status.h"
|
||
#include "utf8.h"
|
||
#include <unistd.h>
|
||
|
||
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);
|
||
|
||
/* Is this message from the local user? Used to draw a 1-column gutter
|
||
* marker so they can scan their own contributions when scrolling. */
|
||
bool is_self = false;
|
||
if (my_username && my_username[0] != '\0' &&
|
||
!system_message_is_system(msg)) {
|
||
if (strcmp(msg->username, "*") == 0) {
|
||
/* /me message: content starts with the actor's username */
|
||
size_t un_len = strlen(my_username);
|
||
if (strncmp(msg->content, my_username, un_len) == 0 &&
|
||
(msg->content[un_len] == ' ' || msg->content[un_len] == '\0')) {
|
||
is_self = true;
|
||
}
|
||
} else if (strcmp(msg->username, my_username) == 0) {
|
||
is_self = true;
|
||
}
|
||
}
|
||
/* Always 1 column wide so all messages align vertically. */
|
||
const char *gutter = is_self ? "\033[36m▎\033[0m" : " ";
|
||
|
||
bool mentioned = false;
|
||
if (my_username && my_username[0] != '\0' &&
|
||
!system_message_is_system(msg)) {
|
||
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 (system_message_is_system(msg)) {
|
||
snprintf(buffer, buf_size,
|
||
"%s\033[90m--> %s\033[0m", gutter, msg->content);
|
||
} else if (strcmp(msg->username, "*") == 0) {
|
||
snprintf(buffer, buf_size,
|
||
"%s\033[90m%s\033[0m \033[3;36m* %s\033[0m",
|
||
gutter, time_str, msg->content);
|
||
} else {
|
||
snprintf(buffer, buf_size,
|
||
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
|
||
gutter, time_str, username_color(msg->username),
|
||
msg->username, hl_start, msg->content, hl_end);
|
||
}
|
||
|
||
/* Plain-text version for width calculation — gutter is 1 column. */
|
||
char plain[MAX_MESSAGE_LEN + 128];
|
||
if (system_message_is_system(msg)) {
|
||
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 — prefix_plain also includes the
|
||
* 1-column gutter so the budget math comes out right. */
|
||
int prefix_width;
|
||
char prefix_plain[256];
|
||
if (system_message_is_system(msg)) {
|
||
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 (system_message_is_system(msg)) {
|
||
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 (system_message_is_system(msg)) {
|
||
snprintf(buffer, buf_size,
|
||
"%s\033[90m--> %s\033[0m", gutter, truncated_content);
|
||
} else if (strcmp(msg->username, "*") == 0) {
|
||
snprintf(buffer, buf_size,
|
||
"%s\033[90m%s\033[0m \033[3;36m%s\033[0m",
|
||
gutter, time_str, truncated_content);
|
||
} else {
|
||
snprintf(buffer, buf_size,
|
||
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
|
||
gutter, 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 = i18n_text(client->ui_lang, I18N_WELCOME_SUBTITLE);
|
||
const char *line3 = i18n_text(client->ui_lang, I18N_WELCOME_TAGLINE);
|
||
|
||
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_text[96];
|
||
char fallback[128];
|
||
snprintf(fallback_text, sizeof(fallback_text),
|
||
i18n_text(client->ui_lang, I18N_WELCOME_FALLBACK_FORMAT),
|
||
TNT_VERSION);
|
||
int n = snprintf(fallback, sizeof(fallback), ANSI_CLEAR ANSI_HOME "%s",
|
||
fallback_text);
|
||
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. The initial slice is capped by
|
||
* message count; the lock-held copy below tightens "latest" slices so
|
||
* date dividers cannot push the newest messages off-screen. */
|
||
int msg_height = history_view_height(render_height);
|
||
|
||
int start = 0;
|
||
int latest_scroll_start = history_view_max_scroll(msg_count, msg_height);
|
||
bool anchor_latest = client->mode != MODE_NORMAL ||
|
||
client->follow_tail ||
|
||
client->scroll_pos >= latest_scroll_start;
|
||
if (client->mode == MODE_NORMAL) {
|
||
start = client->scroll_pos;
|
||
if (start > latest_scroll_start) {
|
||
start = latest_scroll_start;
|
||
}
|
||
if (start < 0) start = 0;
|
||
} else {
|
||
/* INSERT mode: show latest */
|
||
start = latest_scroll_start;
|
||
}
|
||
|
||
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_capacity = msg_height;
|
||
int snapshot_count = end - start;
|
||
|
||
if (snapshot_count > 0 && snapshot_capacity > 0) {
|
||
msg_snapshot = calloc(snapshot_capacity, 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_start = start;
|
||
int actual_end = end;
|
||
if (anchor_latest) {
|
||
actual_end = actual_count;
|
||
actual_start = history_view_latest_start_for_height(
|
||
g_room->messages, actual_count, msg_height);
|
||
} else {
|
||
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
|
||
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
|
||
}
|
||
int actual_snapshot = actual_end - actual_start;
|
||
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
|
||
memcpy(msg_snapshot, &g_room->messages[actual_start],
|
||
actual_snapshot * sizeof(message_t));
|
||
start = actual_start;
|
||
end = actual_end;
|
||
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 (!system_message_is_join_leave(&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), each followed by a dim middle-dot:
|
||
* • bold username
|
||
* • online count
|
||
* • mode name (colour matches the mode itself: cyan/yellow/magenta)
|
||
* • mute marker, only when active
|
||
* • right-aligned hint
|
||
*
|
||
* When the terminal is narrow, drop the optional segments in
|
||
* reverse priority: hint → mute → mode chip → online count, until
|
||
* what's left fits. The bold username is always shown. */
|
||
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),
|
||
i18n_text(client->ui_lang, I18N_TITLE_ONLINE_FORMAT),
|
||
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++;
|
||
|
||
const char *hint = i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT);
|
||
int hint_width = utf8_string_width(hint);
|
||
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
|
||
int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0;
|
||
|
||
/* Unread @-mentions chip — high-priority, gets a bright yellow star.
|
||
* Sits between mode and hint when present, and survives degradation
|
||
* longer than the hint / mute / mode chips. */
|
||
int unread_count = client->unread_mentions;
|
||
char unread_buf[32] = "";
|
||
int unread_width = 0;
|
||
if (unread_count > 0) {
|
||
snprintf(unread_buf, sizeof(unread_buf), "★ %d", unread_count);
|
||
unread_width = utf8_string_width(unread_buf) + 2; /* leading " · " minus initial space accounted later */
|
||
}
|
||
|
||
/* Unread whispers chip — bright magenta envelope. Same priority as
|
||
* the mentions chip; both signal "you missed something". */
|
||
int whisper_count = client->unread_whispers;
|
||
char whisper_buf[32] = "";
|
||
int whisper_width = 0;
|
||
if (whisper_count > 0) {
|
||
snprintf(whisper_buf, sizeof(whisper_buf), "✉ %d", whisper_count);
|
||
whisper_width = utf8_string_width(whisper_buf) + 2;
|
||
}
|
||
|
||
/* Decide what fits. Reserve at least 1 col of gap between left and
|
||
* right halves so they never visually touch. */
|
||
int show_hint = 1;
|
||
int show_mute = client->mute_joins ? 1 : 0;
|
||
int show_unread = unread_count > 0 ? 1 : 0;
|
||
int show_whisper = whisper_count > 0 ? 1 : 0;
|
||
int show_chips = chip_count;
|
||
|
||
while (show_chips > 1) {
|
||
int left_w = 1 /*leading space*/;
|
||
for (int i = 0; i < show_chips; i++) {
|
||
if (i > 0) left_w += 3; /* " · " */
|
||
left_w += utf8_string_width(chips[i].value);
|
||
}
|
||
if (show_mute) left_w += mute_width;
|
||
if (show_unread) left_w += unread_width + 1;
|
||
if (show_whisper) left_w += whisper_width + 1;
|
||
int right_w = (show_hint ? hint_width + 1 /*trailing space*/ : 0);
|
||
int needed = left_w + 1 /*min gap*/ + right_w;
|
||
if (needed <= render_width) break;
|
||
|
||
/* Drop priority: hint → mute → mode → online → whispers → mentions. */
|
||
if (show_hint) { show_hint = 0; continue; }
|
||
if (show_mute) { show_mute = 0; continue; }
|
||
if (show_chips > 1) { show_chips--; continue; }
|
||
if (show_whisper) { show_whisper = 0; continue; }
|
||
if (show_unread) { show_unread = 0; continue; }
|
||
break;
|
||
}
|
||
|
||
/* Compose left half. */
|
||
char left[256];
|
||
size_t lpos = 0;
|
||
int left_width = 0;
|
||
for (int i = 0; i < show_chips; 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 (show_mute) {
|
||
buffer_appendf(left, sizeof(left), &lpos,
|
||
" \033[2;37m%s\033[0m", mute_label);
|
||
left_width += mute_width;
|
||
}
|
||
if (show_unread) {
|
||
buffer_appendf(left, sizeof(left), &lpos,
|
||
" \033[1;33m%s\033[0m", unread_buf);
|
||
left_width += unread_width + 1;
|
||
}
|
||
if (show_whisper) {
|
||
buffer_appendf(left, sizeof(left), &lpos,
|
||
" \033[1;35m%s\033[0m", whisper_buf);
|
||
left_width += whisper_width + 1;
|
||
}
|
||
|
||
int gap = render_width - left_width - (show_hint ? hint_width + 2 : 1);
|
||
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);
|
||
}
|
||
if (show_hint) {
|
||
buffer_appendf(buffer, buf_size, &pos,
|
||
"\033[2;37m%s\033[0m \033[K\r\n", hint);
|
||
} else {
|
||
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
|
||
}
|
||
|
||
/* Render messages from snapshot. Insert a dim "── YYYY-MM-DD ──" divider
|
||
* before the first message of each new day so the eye can land on dates
|
||
* when scrolling through a long-lived room.
|
||
*
|
||
* We track rows_written separately from snapshot index so dividers
|
||
* compete with messages for the fixed message-area height — they do not
|
||
* push other content off the bottom. */
|
||
int rows_written = 0;
|
||
if (msg_snapshot) {
|
||
char last_date[11] = ""; /* "YYYY-MM-DD" */
|
||
for (int i = 0; i < snapshot_count && rows_written < msg_height; i++) {
|
||
char this_date[11];
|
||
struct tm tmi;
|
||
localtime_r(&msg_snapshot[i].timestamp, &tmi);
|
||
strftime(this_date, sizeof(this_date), "%Y-%m-%d", &tmi);
|
||
|
||
if (strcmp(this_date, last_date) != 0) {
|
||
/* Build divider: "── YYYY-MM-DD " then fill the rest with ─ */
|
||
int prefix_w = 3 + 10 + 1; /* "── 2026-05-17 " in display columns */
|
||
int dash_fill = render_width - prefix_w;
|
||
if (dash_fill < 0) dash_fill = 0;
|
||
|
||
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m── %s ", this_date);
|
||
for (int j = 0; j < dash_fill; j++) {
|
||
buffer_append_bytes(buffer, buf_size, &pos, "─", strlen("─"));
|
||
}
|
||
buffer_appendf(buffer, buf_size, &pos, "\033[0m\033[K\r\n");
|
||
|
||
memcpy(last_date, this_date, sizeof(last_date));
|
||
rows_written++;
|
||
if (rows_written >= msg_height) break;
|
||
}
|
||
|
||
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);
|
||
rows_written++;
|
||
}
|
||
free(msg_snapshot);
|
||
}
|
||
|
||
/* Fill empty lines and clear them */
|
||
for (int i = rows_written; 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 */
|
||
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
|
||
|
||
client_send(client, buffer, pos);
|
||
free(buffer);
|
||
}
|
||
|
||
/* Render the input line.
|
||
*
|
||
* Format: "› <input>" with optional right-aligned length indicator
|
||
* once the buffer is past 80% full. The indicator turns bold-yellow
|
||
* past 95% so users can see further keystrokes will be dropped. */
|
||
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);
|
||
size_t input_bytes = strlen(input);
|
||
|
||
/* Decide whether to show the length gauge and how loud. */
|
||
int gauge_width = 0;
|
||
char gauge[64] = "";
|
||
if (input_bytes > (MAX_MESSAGE_LEN * 8) / 10) { /* > 80 % */
|
||
size_t remaining = (input_bytes < MAX_MESSAGE_LEN)
|
||
? (MAX_MESSAGE_LEN - 1 - input_bytes) : 0;
|
||
const char *color =
|
||
(input_bytes > (MAX_MESSAGE_LEN * 95) / 100) ? "\033[1;33m"
|
||
: "\033[2;37m";
|
||
snprintf(gauge, sizeof(gauge), "%s… %zu B\033[0m", color, remaining);
|
||
/* Plain-text width: " … 1234 B" → 4 + len(digits) + 2 */
|
||
char digits[12];
|
||
snprintf(digits, sizeof(digits), "%zu", remaining);
|
||
gauge_width = 4 + (int)strlen(digits) + 2; /* "… ", digits, " B" + leading space */
|
||
}
|
||
|
||
int avail = rw - 3 - (gauge_width > 0 ? gauge_width + 1 : 0);
|
||
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);
|
||
}
|
||
|
||
/* Compose: cursor to input row, clear line, "› " prompt, input.
|
||
* If a gauge is active, append it right-aligned. */
|
||
if (gauge_width > 0) {
|
||
int displayed_width = utf8_string_width(display);
|
||
int padding = rw - 2 - displayed_width - gauge_width;
|
||
if (padding < 1) padding = 1;
|
||
snprintf(buffer, sizeof(buffer),
|
||
"\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m›\033[0m %s%*s%s",
|
||
rh, display, padding, "", gauge);
|
||
} else {
|
||
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[MAX_COMMAND_OUTPUT_LEN + 1024];
|
||
size_t pos = 0;
|
||
buffer[0] = '\0';
|
||
|
||
/* Clear screen */
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||
|
||
/* Title */
|
||
const char *title = i18n_text(client->ui_lang,
|
||
I18N_COMMAND_OUTPUT_TITLE);
|
||
char title_display[64];
|
||
utf8_ansi_truncate(title, title_display, sizeof(title_display), rw);
|
||
int title_width = utf8_ansi_string_width(title_display);
|
||
int padding = rw - title_width;
|
||
if (padding < 0) padding = 0;
|
||
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s",
|
||
title_display);
|
||
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[MAX_COMMAND_OUTPUT_LEN];
|
||
strncpy(output_copy, client->command_output, sizeof(output_copy) - 1);
|
||
output_copy[sizeof(output_copy) - 1] = '\0';
|
||
|
||
char *lines[256];
|
||
int line_count = 0;
|
||
char *line = strtok(output_copy, "\n");
|
||
while (line && line_count < (int)(sizeof(lines) / sizeof(lines[0]))) {
|
||
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;
|
||
if (max_scroll < 0) max_scroll = 0;
|
||
if (client->command_output_scroll < 0) client->command_output_scroll = 0;
|
||
if (client->command_output_scroll > max_scroll) {
|
||
client->command_output_scroll = max_scroll;
|
||
}
|
||
|
||
int start = client->command_output_scroll;
|
||
int end = start + content_height;
|
||
if (end > line_count) end = line_count;
|
||
|
||
for (int i = start; i < end; i++) {
|
||
char truncated[1024];
|
||
utf8_ansi_truncate(lines[i], truncated, sizeof(truncated), rw);
|
||
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated);
|
||
}
|
||
|
||
for (int i = end - start; i < content_height; i++) {
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[K\r\n");
|
||
}
|
||
|
||
buffer_appendf(buffer, sizeof(buffer), &pos,
|
||
i18n_text(client->ui_lang,
|
||
I18N_COMMAND_OUTPUT_STATUS_FORMAT),
|
||
start + 1, max_scroll + 1);
|
||
|
||
client_send(client, buffer, pos);
|
||
}
|
||
|
||
/* Render the MOTD screen.
|
||
*
|
||
* A framed banner with a title chip embedded in the top border and an
|
||
* "any key to continue" hint embedded in the bottom border, MOTD body
|
||
* left-padded inside. Dismissed by handle_key like any other modal
|
||
* (sets command_output[0]='\0' and show_motd=false).
|
||
*
|
||
* Lighter aesthetic than tui_render_command_output: no full-line reverse,
|
||
* dim borders, two blank lines of breathing room above and below the
|
||
* body so the announcement reads as a notice rather than a console dump. */
|
||
void tui_render_motd(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_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||
|
||
/* Top border with a localized title chip. */
|
||
const char *title = i18n_text(client->ui_lang, I18N_MOTD_TITLE);
|
||
int title_w = utf8_string_width(title);
|
||
int top_dash_fill = rw - 2 - title_w - 1; /* 2 corners, 1 leading ─ */
|
||
if (top_dash_fill < 0) top_dash_fill = 0;
|
||
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[2;36m╭─");
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[0;1;36m%s\033[2;36m", title);
|
||
for (int i = 0; i < top_dash_fill; i++) {
|
||
buffer_append_bytes(buffer, sizeof(buffer), &pos, "─", strlen("─"));
|
||
}
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "╮\033[0m\r\n");
|
||
|
||
/* Top breathing-room line */
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
|
||
|
||
/* Body lines (left-pad 2 cols, truncate to inner width) */
|
||
char body_copy[2048];
|
||
strncpy(body_copy, client->command_output, sizeof(body_copy) - 1);
|
||
body_copy[sizeof(body_copy) - 1] = '\0';
|
||
|
||
int body_lines = 0;
|
||
int max_body_lines = rh - 4; /* top border + top pad + bottom pad + bottom border */
|
||
if (max_body_lines < 1) max_body_lines = 1;
|
||
|
||
char *line = strtok(body_copy, "\n");
|
||
while (line && body_lines < max_body_lines) {
|
||
char truncated[1024];
|
||
strncpy(truncated, line, sizeof(truncated) - 1);
|
||
truncated[sizeof(truncated) - 1] = '\0';
|
||
|
||
int avail = rw - 4; /* 2 cols padding each side */
|
||
if (avail < 4) avail = 4;
|
||
if (utf8_string_width(truncated) > avail) {
|
||
utf8_truncate(truncated, avail);
|
||
}
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, " %s\r\n", truncated);
|
||
body_lines++;
|
||
line = strtok(NULL, "\n");
|
||
}
|
||
|
||
/* Fill empty space up to the bottom border */
|
||
int used_rows = 1 /*top*/ + 1 /*pad*/ + body_lines + 1 /*pad*/ + 1 /*bottom*/;
|
||
int filler_rows = rh - used_rows;
|
||
if (filler_rows < 0) filler_rows = 0;
|
||
for (int i = 0; i < filler_rows; i++) {
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
|
||
}
|
||
|
||
/* Bottom breathing-room line */
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
|
||
|
||
/* Bottom border with a localized continue hint. */
|
||
const char *footer = i18n_text(client->ui_lang,
|
||
I18N_MOTD_CONTINUE_HINT);
|
||
int footer_w = utf8_string_width(footer);
|
||
int bot_dash_fill = rw - 2 - footer_w - 1;
|
||
if (bot_dash_fill < 0) bot_dash_fill = 0;
|
||
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[2;36m╰─");
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[0;2;37m%s\033[2;36m", footer);
|
||
for (int i = 0; i < bot_dash_fill; i++) {
|
||
buffer_append_bytes(buffer, sizeof(buffer), &pos, "─", strlen("─"));
|
||
}
|
||
buffer_appendf(buffer, sizeof(buffer), &pos, "╯\033[0m");
|
||
|
||
client_send(client, buffer, pos);
|
||
}
|
||
/* 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 = i18n_text(client->ui_lang, I18N_HELP_TITLE);
|
||
int title_width = utf8_string_width(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");
|
||
|
||
char help_copy[8192];
|
||
size_t help_pos = 0;
|
||
help_copy[0] = '\0';
|
||
help_text_append_full(help_copy, sizeof(help_copy), &help_pos,
|
||
client->ui_lang);
|
||
|
||
/* 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,
|
||
i18n_text(client->ui_lang, I18N_HELP_STATUS_FORMAT),
|
||
start + 1, max_scroll + 1);
|
||
|
||
client_send(client, buffer, pos);
|
||
}
|