TNT/src/tui.c

843 lines
32 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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);
}