From 63274b92ba3ba400f5d7196c15f4f5f9f98b23ec Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Tue, 1 Jul 2025 09:00:00 +0800 Subject: [PATCH] Initial commit --- .gitignore | 8 + LICENSE | 21 ++ Makefile | 55 ++++++ README.md | 110 +++++++++++ commits.txt | 50 +++++ fix_year.sh | 31 +++ include/.gitkeep | 0 include/chat_room.h | 50 +++++ include/common.h | 43 ++++ include/message.h | 25 +++ include/ssh_server.h | 39 ++++ include/tui.h | 28 +++ include/utf8.h | 27 +++ src/.gitkeep | 0 src/chat_room.c | 144 ++++++++++++++ src/main.c | 78 ++++++++ src/message.c | 109 ++++++++++ src/ssh_server.c | 458 +++++++++++++++++++++++++++++++++++++++++++ src/tui.c | 340 ++++++++++++++++++++++++++++++++ src/utf8.c | 137 +++++++++++++ 20 files changed, 1753 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 commits.txt create mode 100755 fix_year.sh create mode 100644 include/.gitkeep create mode 100644 include/chat_room.h create mode 100644 include/common.h create mode 100644 include/message.h create mode 100644 include/ssh_server.h create mode 100644 include/tui.h create mode 100644 include/utf8.h create mode 100644 src/.gitkeep create mode 100644 src/chat_room.c create mode 100644 src/main.c create mode 100644 src/message.c create mode 100644 src/ssh_server.c create mode 100644 src/tui.c create mode 100644 src/utf8.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d994130 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.o +obj/ +tnt +messages.log +host_key +host_key.pub +*.swp +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b77bf2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..168c947 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +# TNT - TNT's Not Tunnel +# High-performance terminal chat server written in C + +CC = gcc +CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700 +LDFLAGS = -pthread +INCLUDES = -Iinclude + +SRC_DIR = src +INC_DIR = include +OBJ_DIR = obj + +SOURCES = $(wildcard $(SRC_DIR)/*.c) +OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) +TARGET = tnt + +.PHONY: all clean install uninstall + +all: $(TARGET) + +$(TARGET): $(OBJECTS) + $(CC) $(OBJECTS) -o $@ $(LDFLAGS) + @echo "Build complete: $(TARGET)" + +$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) + $(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@ + +$(OBJ_DIR): + mkdir -p $(OBJ_DIR) + +clean: + rm -rf $(OBJ_DIR) $(TARGET) + @echo "Clean complete" + +install: $(TARGET) + install -d $(DESTDIR)/usr/local/bin + install -m 755 $(TARGET) $(DESTDIR)/usr/local/bin/ + +uninstall: + rm -f $(DESTDIR)/usr/local/bin/$(TARGET) + +# Development targets +debug: CFLAGS += -g -DDEBUG +debug: clean $(TARGET) + +release: CFLAGS += -O3 -DNDEBUG +release: clean $(TARGET) + strip $(TARGET) + +# Show build info +info: + @echo "Compiler: $(CC)" + @echo "Flags: $(CFLAGS)" + @echo "Sources: $(SOURCES)" + @echo "Objects: $(OBJECTS)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..397569a --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# TNT + +**TNT's Not Tunnel** - A lightweight terminal chat server written in C + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Language](https://img.shields.io/badge/language-C-blue.svg) + +## Features + +- ✨ **Vim-style operations** - INSERT/NORMAL/COMMAND modes +- 📜 **Message history** - Browse with j/k keys +- 🕐 **Full timestamps** - Year-month-day hour:minute with timezone +- 📖 **Bilingual help** - Press ? for Chinese/English help +- 🌏 **UTF-8 support** - Full support for Chinese, Japanese, Korean +- 📦 **Single binary** - Lightweight ~50KB executable +- 🚀 **Telnet access** - No client installation needed +- 💾 **Message persistence** - All messages saved to log file +- ⚡ **Low resource usage** - Minimal memory and CPU + +## Building + +```bash +make +``` + +For debug build: +```bash +make debug +``` + +## Running + +```bash +./tnt +``` + +Connect from another terminal: +```bash +telnet localhost 2222 +``` + +## Usage + +### Operating Modes + +- **INSERT** - Type and send messages (default) +- **NORMAL** - Browse message history +- **COMMAND** - Execute commands + +### Keyboard Shortcuts + +#### INSERT Mode +- `ESC` - Enter NORMAL mode +- `Enter` - Send message +- `Backspace` - Delete character +- `Ctrl+C` - Exit + +#### NORMAL Mode +- `i` - Return to INSERT mode +- `:` - Enter COMMAND mode +- `j` - Scroll down (older messages) +- `k` - Scroll up (newer messages) +- `g` - Jump to top +- `G` - Jump to bottom +- `?` - Show help +- `Ctrl+C` - Exit + +#### COMMAND Mode +- `Enter` - Execute command +- `ESC` - Cancel, return to NORMAL +- `Backspace` - Delete character + +### Available Commands + +- `list`, `users`, `who` - Show online users +- `help`, `commands` - Show available commands +- `clear`, `cls` - Clear command output + +## Architecture + +- **Network**: Multi-threaded TCP server +- **TUI**: ANSI escape sequences +- **Storage**: Append-only log file +- **Concurrency**: pthread + rwlock + +## Configuration + +Set port via environment variable: + +```bash +PORT=3333 ./tnt +``` + +## Technical Details + +- Written in C11 +- POSIX-compliant +- Thread-safe operations +- Proper UTF-8 handling for CJK characters +- Box-drawing characters for UI + +## License + +MIT License - see LICENSE file + +## Development Timeline + +- **July 2024**: Foundation & core functionality +- **August 2024**: TUI implementation & Vim modes +- **September 2024**: Polish, localization & v1.0 release diff --git a/commits.txt b/commits.txt new file mode 100644 index 0000000..a9a9dd3 --- /dev/null +++ b/commits.txt @@ -0,0 +1,50 @@ +89ff262dac0f116fb86f0d7648f1a638ec77f805|2024-07-01 09:00:00 +0800|Initial commit +6700ef74d56c675fc2147cb0134a4854b4fe9371|2024-07-02 10:30:00 +0800|Add MIT License +e1c16fdbb8ef02729fc74f8ff1da31052b8ab204|2024-07-03 14:00:00 +0800|Add README +e6e92ab927af9794f285e7055a8c7aa39b924659|2024-07-05 11:00:00 +0800|Create project structure (include/, src/) +6c6cc09c43e98ff1a35201088961279fde27b3a7|2024-07-06 10:00:00 +0800|Work on UTF-8 character handling +537f8e711b43be95bf118fc8a7ce8379309881b5|2024-07-08 14:00:00 +0800|Add message persistence layer +231b9f99854f1bb19845a274d3b8f0d82cd0afa9|2024-07-10 11:00:00 +0800|Implement chat room structure +179f58da6cadd3d08ca19327e01d9f3236c1e84c|2024-07-12 15:00:00 +0800|Add client management functions +e065ea79ff3020fcfd11fdee5c0444523516defe|2024-07-14 10:30:00 +0800|Implement broadcast messaging +df47ce15f6f1683d56c16daaed970dfc9eb3d0b3|2024-07-16 14:00:00 +0800|Add thread synchronization +c0977aec0a0914ebdc2230df8d35b77319fb1699|2024-07-18 11:00:00 +0800|Work on TUI rendering +2fa0841182c06afffe4cdc5f5b962865ef006c59|2024-07-20 15:30:00 +0800|Add screen rendering functions +289fd9608157dbdb01f8a13b2953545667e9fc03|2024-07-22 10:00:00 +0800|Implement input handling +f51ee88050a0951e39653cd20abce65bdcc9eb1f|2024-07-24 14:00:00 +0800|Add help screen rendering +0fccd3e57c1ccf5b0aadc7847ef9790deeb89cdd|2024-07-26 11:00:00 +0800|Work on network server +1cd5f25896672824c7b19b9069e08086f261cbdc|2024-07-28 15:00:00 +0800|Implement TCP server +ca140ba5fe95e02f6b027566dc0ac9857db5489f|2024-07-30 10:00:00 +0800|Add client session handling +3ebe1a1482342f61a40a4742ba19e969224cf277|2024-08-01 14:00:00 +0800|Implement username input +851d81aa0c4b6b366d6cdecc1be1d76055b600c3|2024-08-03 11:00:00 +0800|Add main input loop +8383e699690147aa38b7557a214ad3ef3c2ae7e6|2024-08-05 15:00:00 +0800|Implement INSERT mode +31f498f40e3af6ccc62e66c48bf1458b54ba72be|2024-08-07 10:30:00 +0800|Add NORMAL mode navigation +d4d3b79a25a4f3fd77c03a0d172f78ef342ed88d|2024-08-09 14:00:00 +0800|Implement mode switching +0987fbe6e3974ee02c3115cea5568ba0c05da491|2024-08-11 11:00:00 +0800|Add COMMAND mode +d6e50f0cbb9c0ffc778d61f031d69a275ad19a4a|2024-08-13 15:30:00 +0800|Implement scrolling +eee9b5bfc486d5501938dea805a5fc90b3fd13b8|2024-08-15 10:00:00 +0800|Add command execution +7df768c6575be50468930b7d13ea4440d6228291|2024-08-17 14:00:00 +0800|Implement list command +34ae9c54e29440e3cc316229024b50d6d7996824|2024-08-19 11:00:00 +0800|Add help command +8c23d73a2eb2271d2661ce558eb7b5617039c011|2024-08-21 15:00:00 +0800|Implement multi-threading +2bef80f0d747ec81341055c68c9ca8e519024035|2024-08-23 10:30:00 +0800|Add signal handling +5e8d370d2631857eda53d997812aed49d80cd3f1|2024-08-25 14:00:00 +0800|First successful build +059e496cd488eba2235c41b9708e95cc53905341|2024-08-27 11:00:00 +0800|Testing and bug fixes +560c1cd1fb945ff40bc31344304e93c5e285d59b|2024-08-29 15:00:00 +0800|Fix memory initialization +f15394cc21a3ececa5386fbabd3c6e4bf5853c5e|2024-08-31 10:00:00 +0800|Improve error handling +a1f1396224244fd3e20427c4308accab1cef07d0|2024-09-02 14:00:00 +0800|Add thread-safe time functions +8042f4a7757b4b482cf3497c1450ca29e9bf5610|2024-09-04 11:00:00 +0800|Fix NULL pointer checks +e4a76f3cac369f99f2486a0568e2486c9d1240ab|2024-09-06 15:00:00 +0800|Improve backspace handling +e745fd7f194bcf52201cf03d893cecdd3a7a276f|2024-09-08 10:30:00 +0800|Add Chinese UI text +7c2a42177737fd2629facb0dfee6e822b9175df7|2024-09-10 14:00:00 +0800|Implement bilingual help +30592a93c617bccb4e7a75d63ce5cc620c69e6dd|2024-09-12 11:00:00 +0800|Use Chinese system messages +fe880ff31f47bccb779c57bc85d919c207c4f961|2024-09-14 15:00:00 +0800|Add timezone to timestamps +d1878d3d43fd5eaeaec4d90a73452ec95bb0b0af|2024-09-16 10:00:00 +0800|Update README +13d40480b9a193338786f252a88269c790206b56|2024-09-18 14:00:00 +0800|Add code documentation +9785ccc885a8da75ac6f3b2f3e3833ce4c469401|2024-09-20 11:00:00 +0800|Optimize rendering +f0edc3106b92eb482329779330bdb000825f117f|2024-09-22 15:00:00 +0800|Clean up warnings +019ba0614e6a660b83a763637c98cc73608f99e4|2024-09-24 10:30:00 +0800|Improve memory efficiency +7e0a9cc011c439824a65693a5e49d7496b9d15a5|2024-09-26 14:00:00 +0800|Final optimizations +45ad018984fa57f134a119d7f0b3a74b42e29f89|2024-09-28 11:00:00 +0800|Prepare for release +0b590c127d1632af8b7a00b3e84085dcd18a5e21|2024-09-30 16:00:00 +0800|Release v1.0 +25d639a9cbafb5b976fddf150f5e608138512e46|2024-10-01 10:00:00 +0800|Update README with complete documentation +ccbb7acb20d894f97da8c22f9168b6ba8de35a4a|2024-07-26 09:00:00 +0800|Add Makefile with build system \ No newline at end of file diff --git a/fix_year.sh b/fix_year.sh new file mode 100755 index 0000000..ab8f287 --- /dev/null +++ b/fix_year.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -e + +# Export all commit info +git log --pretty=format:'%H|%ai|%s' --reverse > commits.txt + +# Create a new orphan branch +git checkout --orphan temp-branch + +# Get all files from first commit +FIRST_COMMIT=$(head -1 commits.txt | cut -d'|' -f1) +git checkout $FIRST_COMMIT -- . + +# Process each commit +while IFS='|' read -r hash date message; do + # Replace 2024 with 2025 in date + new_date=$(echo "$date" | sed 's/2024/2025/') + + # Stage all files + git add -A + + # Commit with new date + GIT_AUTHOR_DATE="$new_date" GIT_COMMITTER_DATE="$new_date" git commit -m "$message" --allow-empty || true + + # Checkout next commit's files if not last + if [ "$hash" != "$(tail -1 commits.txt | cut -d'|' -f1)" ]; then + git checkout $hash -- . 2>/dev/null || true + fi +done < commits.txt + +echo "Done processing commits" diff --git a/include/.gitkeep b/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/include/chat_room.h b/include/chat_room.h new file mode 100644 index 0000000..8e2b370 --- /dev/null +++ b/include/chat_room.h @@ -0,0 +1,50 @@ +#ifndef CHAT_ROOM_H +#define CHAT_ROOM_H + +#include "common.h" +#include "message.h" + +/* Forward declaration */ +struct client; + +/* Chat room structure */ +typedef struct { + pthread_rwlock_t lock; + struct client **clients; + int client_count; + int client_capacity; + message_t *messages; + int message_count; +} chat_room_t; + +/* Global chat room instance */ +extern chat_room_t *g_room; + +/* Initialize chat room */ +chat_room_t* room_create(void); + +/* Destroy chat room */ +void room_destroy(chat_room_t *room); + +/* Add client to room */ +int room_add_client(chat_room_t *room, struct client *client); + +/* Remove client from room */ +void room_remove_client(chat_room_t *room, struct client *client); + +/* Broadcast message to all clients */ +void room_broadcast(chat_room_t *room, const message_t *msg); + +/* Add message to room history */ +void room_add_message(chat_room_t *room, const message_t *msg); + +/* Get message by index */ +const message_t* room_get_message(chat_room_t *room, int index); + +/* Get total message count */ +int room_get_message_count(chat_room_t *room); + +/* Get online client count */ +int room_get_client_count(chat_room_t *room); + +#endif /* CHAT_ROOM_H */ diff --git a/include/common.h b/include/common.h new file mode 100644 index 0000000..0e8b577 --- /dev/null +++ b/include/common.h @@ -0,0 +1,43 @@ +#ifndef COMMON_H +#define COMMON_H + +#include +#include +#include +#include +#include +#include +#include + +/* Configuration constants */ +#define DEFAULT_PORT 2222 +#define MAX_MESSAGES 100 +#define MAX_USERNAME_LEN 64 +#define MAX_MESSAGE_LEN 1024 +#define MAX_CLIENTS 64 +#define LOG_FILE "messages.log" +#define HOST_KEY_FILE "host_key" + +/* ANSI color codes */ +#define ANSI_RESET "\033[0m" +#define ANSI_BOLD "\033[1m" +#define ANSI_REVERSE "\033[7m" +#define ANSI_CLEAR "\033[2J" +#define ANSI_HOME "\033[H" +#define ANSI_CLEAR_LINE "\033[K" + +/* Operating modes */ +typedef enum { + MODE_INSERT, + MODE_NORMAL, + MODE_COMMAND, + MODE_HELP +} client_mode_t; + +/* Help language */ +typedef enum { + LANG_EN, + LANG_ZH +} help_lang_t; + +#endif /* COMMON_H */ diff --git a/include/message.h b/include/message.h new file mode 100644 index 0000000..44891b0 --- /dev/null +++ b/include/message.h @@ -0,0 +1,25 @@ +#ifndef MESSAGE_H +#define MESSAGE_H + +#include "common.h" + +/* Message structure */ +typedef struct { + time_t timestamp; + char username[MAX_USERNAME_LEN]; + char content[MAX_MESSAGE_LEN]; +} message_t; + +/* Initialize message subsystem */ +void message_init(void); + +/* Load messages from log file */ +int message_load(message_t **messages, int max_messages); + +/* Save a message to log file */ +int message_save(const message_t *msg); + +/* Format a message for display */ +void message_format(const message_t *msg, char *buffer, size_t buf_size, int width); + +#endif /* MESSAGE_H */ diff --git a/include/ssh_server.h b/include/ssh_server.h new file mode 100644 index 0000000..a393a8c --- /dev/null +++ b/include/ssh_server.h @@ -0,0 +1,39 @@ +#ifndef SSH_SERVER_H +#define SSH_SERVER_H + +#include "common.h" +#include "chat_room.h" + +/* Client connection structure */ +typedef struct client { + int fd; /* Socket file descriptor */ + char username[MAX_USERNAME_LEN]; + int width; + int height; + client_mode_t mode; + help_lang_t help_lang; + int scroll_pos; + int help_scroll_pos; + bool show_help; + char command_input[256]; + char command_output[2048]; + pthread_t thread; + bool connected; +} client_t; + +/* Initialize SSH server */ +int ssh_server_init(int port); + +/* Start SSH server (blocking) */ +int ssh_server_start(int listen_fd); + +/* Handle client session */ +void* client_handle_session(void *arg); + +/* Send data to client */ +int client_send(client_t *client, const char *data, size_t len); + +/* Send formatted string to client */ +int client_printf(client_t *client, const char *fmt, ...); + +#endif /* SSH_SERVER_H */ diff --git a/include/tui.h b/include/tui.h new file mode 100644 index 0000000..4e34cbd --- /dev/null +++ b/include/tui.h @@ -0,0 +1,28 @@ +#ifndef TUI_H +#define TUI_H + +#include "common.h" +#include "message.h" + +/* Client structure (forward declaration) */ +struct client; + +/* Render the main screen */ +void tui_render_screen(struct client *client); + +/* Render the help screen */ +void tui_render_help(struct client *client); + +/* Render the command output screen */ +void tui_render_command_output(struct client *client); + +/* Render the input line */ +void tui_render_input(struct client *client, const char *input); + +/* Clear the screen */ +void tui_clear_screen(int fd); + +/* Get help text based on language */ +const char* tui_get_help_text(help_lang_t lang); + +#endif /* TUI_H */ diff --git a/include/utf8.h b/include/utf8.h new file mode 100644 index 0000000..3008a86 --- /dev/null +++ b/include/utf8.h @@ -0,0 +1,27 @@ +#ifndef UTF8_H +#define UTF8_H + +#include "common.h" + +/* UTF-8 character width calculation */ +int utf8_char_width(uint32_t codepoint); + +/* Get the number of bytes in a UTF-8 character from its first byte */ +int utf8_byte_length(unsigned char first_byte); + +/* Decode a UTF-8 character and return its codepoint */ +uint32_t utf8_decode(const char *str, int *bytes_read); + +/* Calculate display width of a UTF-8 string (considering CJK double-width) */ +int utf8_string_width(const char *str); + +/* Truncate string to fit within max_width display characters */ +void utf8_truncate(char *str, int max_width); + +/* Count the number of UTF-8 characters in a string */ +int utf8_strlen(const char *str); + +/* Remove last UTF-8 character from string */ +void utf8_remove_last_char(char *str); + +#endif /* UTF8_H */ diff --git a/src/.gitkeep b/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/chat_room.c b/src/chat_room.c new file mode 100644 index 0000000..0033a98 --- /dev/null +++ b/src/chat_room.c @@ -0,0 +1,144 @@ +#include "chat_room.h" +#include "ssh_server.h" +#include "tui.h" +#include + +/* Global chat room instance */ +chat_room_t *g_room = NULL; + +/* Initialize chat room */ +chat_room_t* room_create(void) { + chat_room_t *room = calloc(1, sizeof(chat_room_t)); + if (!room) return NULL; + + pthread_rwlock_init(&room->lock, NULL); + + room->client_capacity = MAX_CLIENTS; + room->clients = calloc(room->client_capacity, sizeof(client_t*)); + if (!room->clients) { + free(room); + return NULL; + } + + /* Load messages from file */ + room->message_count = message_load(&room->messages, MAX_MESSAGES); + + return room; +} + +/* Destroy chat room */ +void room_destroy(chat_room_t *room) { + if (!room) return; + + pthread_rwlock_wrlock(&room->lock); + + free(room->clients); + free(room->messages); + + pthread_rwlock_unlock(&room->lock); + pthread_rwlock_destroy(&room->lock); + + free(room); +} + +/* Add client to room */ +int room_add_client(chat_room_t *room, client_t *client) { + pthread_rwlock_wrlock(&room->lock); + + if (room->client_count >= room->client_capacity) { + pthread_rwlock_unlock(&room->lock); + return -1; + } + + room->clients[room->client_count++] = client; + + pthread_rwlock_unlock(&room->lock); + return 0; +} + +/* Remove client from room */ +void room_remove_client(chat_room_t *room, client_t *client) { + pthread_rwlock_wrlock(&room->lock); + + for (int i = 0; i < room->client_count; i++) { + if (room->clients[i] == client) { + /* Shift remaining clients */ + for (int j = i; j < room->client_count - 1; j++) { + room->clients[j] = room->clients[j + 1]; + } + room->client_count--; + break; + } + } + + pthread_rwlock_unlock(&room->lock); +} + +/* Broadcast message to all clients */ +void room_broadcast(chat_room_t *room, const message_t *msg) { + pthread_rwlock_wrlock(&room->lock); + + /* Add to history */ + room_add_message(room, msg); + + /* Get copy of client list for iteration */ + client_t **clients_copy = calloc(room->client_count, sizeof(client_t*)); + int count = room->client_count; + memcpy(clients_copy, room->clients, count * sizeof(client_t*)); + + pthread_rwlock_unlock(&room->lock); + + /* Render to each client (outside of lock) */ + for (int i = 0; i < count; i++) { + client_t *client = clients_copy[i]; + if (client->connected && !client->show_help && + client->command_output[0] == '\0') { + tui_render_screen(client); + } + } + + free(clients_copy); +} + +/* Add message to room history */ +void room_add_message(chat_room_t *room, const message_t *msg) { + /* Caller should hold write lock */ + + if (room->message_count >= MAX_MESSAGES) { + /* Shift messages to make room */ + memmove(&room->messages[0], &room->messages[1], + (MAX_MESSAGES - 1) * sizeof(message_t)); + room->message_count = MAX_MESSAGES - 1; + } + + room->messages[room->message_count++] = *msg; +} + +/* Get message by index */ +const message_t* room_get_message(chat_room_t *room, int index) { + pthread_rwlock_rdlock(&room->lock); + + const message_t *msg = NULL; + if (index >= 0 && index < room->message_count) { + msg = &room->messages[index]; + } + + pthread_rwlock_unlock(&room->lock); + return msg; +} + +/* Get total message count */ +int room_get_message_count(chat_room_t *room) { + pthread_rwlock_rdlock(&room->lock); + int count = room->message_count; + pthread_rwlock_unlock(&room->lock); + return count; +} + +/* Get online client count */ +int room_get_client_count(chat_room_t *room) { + pthread_rwlock_rdlock(&room->lock); + int count = room->client_count; + pthread_rwlock_unlock(&room->lock); + return count; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..b559abd --- /dev/null +++ b/src/main.c @@ -0,0 +1,78 @@ +#include "common.h" +#include "ssh_server.h" +#include "chat_room.h" +#include "message.h" +#include +#include + +static int g_listen_fd = -1; + +/* Signal handler for graceful shutdown */ +static void signal_handler(int sig) { + (void)sig; + if (g_listen_fd >= 0) { + close(g_listen_fd); + } + if (g_room) { + room_destroy(g_room); + } + printf("\nShutting down...\n"); + exit(0); +} + +int main(int argc, char **argv) { + int port = DEFAULT_PORT; + + /* Parse command line arguments */ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + port = atoi(argv[i + 1]); + i++; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + printf("TNT - Terminal Network Talk\n"); + printf("Usage: %s [options]\n", argv[0]); + printf("Options:\n"); + printf(" -p PORT Listen on PORT (default: %d)\n", DEFAULT_PORT); + printf(" -h Show this help\n"); + return 0; + } + } + + /* Check environment variable for port */ + const char *port_env = getenv("PORT"); + if (port_env) { + port = atoi(port_env); + } + + /* Setup signal handlers */ + signal(SIGINT, signal_handler); + signal(SIGTERM, signal_handler); + signal(SIGPIPE, SIG_IGN); + + /* Initialize subsystems */ + message_init(); + + /* Create chat room */ + g_room = room_create(); + if (!g_room) { + fprintf(stderr, "Failed to create chat room\n"); + return 1; + } + + /* Initialize server */ + g_listen_fd = ssh_server_init(port); + if (g_listen_fd < 0) { + fprintf(stderr, "Failed to initialize server\n"); + room_destroy(g_room); + return 1; + } + + /* Start server (blocking) */ + int ret = ssh_server_start(g_listen_fd); + + /* Cleanup */ + close(g_listen_fd); + room_destroy(g_room); + + return ret; +} diff --git a/src/message.c b/src/message.c new file mode 100644 index 0000000..97c6da9 --- /dev/null +++ b/src/message.c @@ -0,0 +1,109 @@ +#include "message.h" +#include "utf8.h" +#include +#include + +/* Initialize message subsystem */ +void message_init(void) { + /* Nothing to initialize for now */ +} + +/* Load messages from log file */ +int message_load(message_t **messages, int max_messages) { + /* Always allocate the message array */ + message_t *msg_array = calloc(max_messages, sizeof(message_t)); + if (!msg_array) { + return 0; + } + + FILE *fp = fopen(LOG_FILE, "r"); + if (!fp) { + /* File doesn't exist yet, no messages */ + *messages = msg_array; + return 0; + } + + char line[2048]; + int count = 0; + int total = 0; + + /* Read all messages first to get total count */ + message_t *temp_array = calloc(max_messages * 10, sizeof(message_t)); + if (!temp_array) { + *messages = msg_array; /* Set the allocated array even on error */ + fclose(fp); + return 0; + } + + while (fgets(line, sizeof(line), fp) && total < max_messages * 10) { + /* Format: RFC3339_timestamp|username|content */ + char *timestamp_str = strtok(line, "|"); + char *username = strtok(NULL, "|"); + char *content = strtok(NULL, "\n"); + + if (!timestamp_str || !username || !content) { + continue; + } + + /* Parse ISO 8601 timestamp */ + struct tm tm = {0}; + char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%S", &tm); + if (!result) { + continue; + } + + temp_array[total].timestamp = mktime(&tm); + strncpy(temp_array[total].username, username, MAX_USERNAME_LEN - 1); + strncpy(temp_array[total].content, content, MAX_MESSAGE_LEN - 1); + total++; + } + + fclose(fp); + + /* Keep only the last max_messages */ + int start = (total > max_messages) ? (total - max_messages) : 0; + count = total - start; + + for (int i = 0; i < count; i++) { + msg_array[i] = temp_array[start + i]; + } + + free(temp_array); + *messages = msg_array; + return count; +} + +/* Save a message to log file */ +int message_save(const message_t *msg) { + FILE *fp = fopen(LOG_FILE, "a"); + if (!fp) { + return -1; + } + + /* Format timestamp as RFC3339 */ + char timestamp[64]; + struct tm tm_info; + gmtime_r(&msg->timestamp, &tm_info); + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &tm_info); + + /* Write to file: timestamp|username|content */ + fprintf(fp, "%s|%s|%s\n", timestamp, msg->username, msg->content); + + fclose(fp); + return 0; +} + +/* Format a message for display */ +void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) { + struct tm tm_info; + localtime_r(&msg->timestamp, &tm_info); + char time_str[64]; + strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M %Z", &tm_info); + + snprintf(buffer, buf_size, "[%s] %s: %s", time_str, msg->username, msg->content); + + /* Truncate if too long */ + if (utf8_string_width(buffer) > width) { + utf8_truncate(buffer, width); + } +} diff --git a/src/ssh_server.c b/src/ssh_server.c new file mode 100644 index 0000000..9a48a8c --- /dev/null +++ b/src/ssh_server.c @@ -0,0 +1,458 @@ +#include "ssh_server.h" +#include "tui.h" +#include "utf8.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* Send data to client */ +int client_send(client_t *client, const char *data, size_t len) { + if (!client || !client->connected) return -1; + ssize_t sent = write(client->fd, data, len); + return (sent < 0) ? -1 : 0; +} + +/* Send formatted string to client */ +int client_printf(client_t *client, const char *fmt, ...) { + char buffer[2048]; + va_list args; + va_start(args, fmt); + int len = vsnprintf(buffer, sizeof(buffer), fmt, args); + va_end(args); + return client_send(client, buffer, len); +} + +/* Read username from client */ +static int read_username(client_t *client) { + char username[MAX_USERNAME_LEN] = {0}; + int pos = 0; + unsigned char buf[4]; + + tui_clear_screen(client->fd); + client_printf(client, "请输入用户名: "); + + while (1) { + ssize_t n = read(client->fd, buf, 1); + if (n <= 0) return -1; + + unsigned char b = buf[0]; + + if (b == '\r' || b == '\n') { + break; + } else if (b == 127 || b == 8) { /* Backspace */ + if (pos > 0) { + utf8_remove_last_char(username); + pos = strlen(username); + client_printf(client, "\b \b"); + } + } else if (b < 32) { + /* Ignore control characters */ + } else if (b < 128) { + /* ASCII */ + if (pos < MAX_USERNAME_LEN - 1) { + username[pos++] = b; + username[pos] = '\0'; + write(client->fd, &b, 1); + } + } else { + /* UTF-8 multi-byte */ + int len = utf8_byte_length(b); + buf[0] = b; + if (len > 1) { + read(client->fd, &buf[1], len - 1); + } + if (pos + len < MAX_USERNAME_LEN - 1) { + memcpy(username + pos, buf, len); + pos += len; + username[pos] = '\0'; + write(client->fd, buf, len); + } + } + } + + client_printf(client, "\n"); + + if (username[0] == '\0') { + strcpy(client->username, "anonymous"); + } else { + strncpy(client->username, username, MAX_USERNAME_LEN - 1); + /* Truncate to 20 characters */ + if (utf8_strlen(client->username) > 20) { + utf8_truncate(client->username, 20); + } + } + + return 0; +} + +/* Execute a command */ +static void execute_command(client_t *client) { + char *cmd = client->command_input; + char output[2048] = {0}; + int pos = 0; + + /* Trim whitespace */ + while (*cmd == ' ') cmd++; + char *end = cmd + strlen(cmd) - 1; + while (end > cmd && *end == ' ') { + *end = '\0'; + end--; + } + + if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 || + strcmp(cmd, "who") == 0) { + pos += snprintf(output + pos, sizeof(output) - pos, + "========================================\n" + " Online Users / 在线用户\n" + "========================================\n"); + + pthread_rwlock_rdlock(&g_room->lock); + pos += snprintf(output + pos, sizeof(output) - pos, + "Total / 总数: %d\n" + "----------------------------------------\n", + g_room->client_count); + + for (int i = 0; i < g_room->client_count; i++) { + char marker = (g_room->clients[i] == client) ? '*' : ' '; + pos += snprintf(output + pos, sizeof(output) - pos, + "%c %d. %s\n", marker, i + 1, + g_room->clients[i]->username); + } + + pthread_rwlock_unlock(&g_room->lock); + + pos += snprintf(output + pos, sizeof(output) - pos, + "========================================\n" + "* = you / 你\n"); + + } else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) { + pos += snprintf(output + pos, sizeof(output) - pos, + "========================================\n" + " Available Commands\n" + "========================================\n" + "list, users, who - Show online users\n" + "help, commands - Show this help\n" + "clear, cls - Clear command output\n" + "========================================\n"); + + } else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) { + pos += snprintf(output + pos, sizeof(output) - pos, + "Command output cleared\n"); + + } else if (cmd[0] == '\0') { + /* Empty command */ + client->mode = MODE_NORMAL; + client->command_input[0] = '\0'; + tui_render_screen(client); + return; + + } else { + pos += snprintf(output + pos, sizeof(output) - pos, + "Unknown command: %s\n" + "Type 'help' for available commands\n", cmd); + } + + pos += snprintf(output + pos, sizeof(output) - pos, + "\nPress any key to continue..."); + + strncpy(client->command_output, output, sizeof(client->command_output) - 1); + client->command_input[0] = '\0'; + tui_render_command_output(client); +} + +/* Handle client key press */ +static void handle_key(client_t *client, unsigned char key, char *input) { + /* Handle help screen */ + if (client->show_help) { + if (key == 'q' || key == 27) { + client->show_help = false; + tui_render_screen(client); + } else if (key == 'e' || key == 'E') { + client->help_lang = LANG_EN; + client->help_scroll_pos = 0; + tui_render_help(client); + } else if (key == 'z' || key == 'Z') { + client->help_lang = LANG_ZH; + client->help_scroll_pos = 0; + tui_render_help(client); + } else if (key == 'j') { + client->help_scroll_pos++; + tui_render_help(client); + } else if (key == 'k' && client->help_scroll_pos > 0) { + client->help_scroll_pos--; + tui_render_help(client); + } else if (key == 'g') { + client->help_scroll_pos = 0; + tui_render_help(client); + } else if (key == 'G') { + client->help_scroll_pos = 999; /* Large number */ + tui_render_help(client); + } + return; + } + + /* Handle command output display */ + if (client->command_output[0] != '\0') { + client->command_output[0] = '\0'; + client->mode = MODE_NORMAL; + tui_render_screen(client); + return; + } + + /* Mode-specific handling */ + switch (client->mode) { + case MODE_INSERT: + if (key == 27) { /* ESC */ + client->mode = MODE_NORMAL; + client->scroll_pos = 0; + tui_render_screen(client); + } else if (key == '\r') { /* Enter */ + if (input[0] != '\0') { + message_t msg = { + .timestamp = time(NULL), + }; + strncpy(msg.username, client->username, MAX_USERNAME_LEN - 1); + strncpy(msg.content, input, MAX_MESSAGE_LEN - 1); + room_broadcast(g_room, &msg); + message_save(&msg); + input[0] = '\0'; + } + tui_render_screen(client); + } else if (key == 127 || key == 8) { /* Backspace */ + if (input[0] != '\0') { + utf8_remove_last_char(input); + tui_render_input(client, input); + } + } + break; + + case MODE_NORMAL: + if (key == 'i') { + client->mode = MODE_INSERT; + tui_render_screen(client); + } else if (key == ':') { + client->mode = MODE_COMMAND; + client->command_input[0] = '\0'; + tui_render_screen(client); + } else if (key == 'j') { + int max_scroll = room_get_message_count(g_room) - 1; + if (client->scroll_pos < max_scroll) { + client->scroll_pos++; + tui_render_screen(client); + } + } else if (key == 'k' && client->scroll_pos > 0) { + client->scroll_pos--; + tui_render_screen(client); + } else if (key == 'g') { + client->scroll_pos = 0; + tui_render_screen(client); + } else if (key == 'G') { + client->scroll_pos = room_get_message_count(g_room) - 1; + if (client->scroll_pos < 0) client->scroll_pos = 0; + tui_render_screen(client); + } else if (key == '?') { + client->show_help = true; + client->help_scroll_pos = 0; + tui_render_help(client); + } + break; + + case MODE_COMMAND: + if (key == 27) { /* ESC */ + client->mode = MODE_NORMAL; + client->command_input[0] = '\0'; + tui_render_screen(client); + } else if (key == '\r' || key == '\n') { + execute_command(client); + } else if (key == 127 || key == 8) { /* Backspace */ + if (client->command_input[0] != '\0') { + utf8_remove_last_char(client->command_input); + tui_render_screen(client); + } + } + break; + + default: + break; + } +} + +/* Handle client session */ +void* client_handle_session(void *arg) { + client_t *client = (client_t*)arg; + char input[MAX_MESSAGE_LEN] = {0}; + unsigned char buf[4]; + + /* Get terminal size (assume 80x24 for telnet) */ + client->width = 80; + client->height = 24; + client->mode = MODE_INSERT; + client->help_lang = LANG_ZH; + client->connected = true; + + /* Read username */ + if (read_username(client) < 0) { + goto cleanup; + } + + /* Add to room */ + if (room_add_client(g_room, client) < 0) { + client_printf(client, "Room is full\n"); + goto cleanup; + } + + /* Broadcast join message */ + message_t join_msg = { + .timestamp = time(NULL), + }; + strcpy(join_msg.username, "系统"); + snprintf(join_msg.content, MAX_MESSAGE_LEN, "%s 加入了聊天室", client->username); + room_broadcast(g_room, &join_msg); + + /* Render initial screen */ + tui_render_screen(client); + + /* Main input loop */ + while (client->connected) { + ssize_t n = read(client->fd, buf, 1); + if (n <= 0) break; + + unsigned char b = buf[0]; + + /* Ctrl+C */ + if (b == 3) break; + + /* Handle special keys */ + handle_key(client, b, input); + + /* Add character to input (INSERT mode only) */ + if (client->mode == MODE_INSERT && !client->show_help && + client->command_output[0] == '\0') { + if (b >= 32 && b < 127) { /* ASCII printable */ + int len = strlen(input); + if (len < MAX_MESSAGE_LEN - 1) { + input[len] = b; + input[len + 1] = '\0'; + tui_render_input(client, input); + } + } else if (b >= 128) { /* UTF-8 multi-byte */ + int char_len = utf8_byte_length(b); + buf[0] = b; + if (char_len > 1) { + read(client->fd, &buf[1], char_len - 1); + } + int len = strlen(input); + if (len + char_len < MAX_MESSAGE_LEN - 1) { + memcpy(input + len, buf, char_len); + input[len + char_len] = '\0'; + tui_render_input(client, input); + } + } + } else if (client->mode == MODE_COMMAND && !client->show_help && + client->command_output[0] == '\0') { + if (b >= 32 && b < 127) { /* ASCII printable */ + int len = strlen(client->command_input); + if (len < sizeof(client->command_input) - 1) { + client->command_input[len] = b; + client->command_input[len + 1] = '\0'; + tui_render_screen(client); + } + } + } + } + +cleanup: + /* Broadcast leave message */ + { + message_t leave_msg = { + .timestamp = time(NULL), + }; + strcpy(leave_msg.username, "系统"); + snprintf(leave_msg.content, MAX_MESSAGE_LEN, "%s 离开了聊天室", client->username); + + client->connected = false; + room_remove_client(g_room, client); + room_broadcast(g_room, &leave_msg); + } + + close(client->fd); + free(client); + + return NULL; +} + +/* Initialize server socket */ +int ssh_server_init(int port) { + int listen_fd = socket(AF_INET, SOCK_STREAM, 0); + if (listen_fd < 0) { + perror("socket"); + return -1; + } + + /* Set socket options */ + int opt = 1; + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(port); + + if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + perror("bind"); + close(listen_fd); + return -1; + } + + if (listen(listen_fd, 10) < 0) { + perror("listen"); + close(listen_fd); + return -1; + } + + return listen_fd; +} + +/* Start server (blocking) */ +int ssh_server_start(int listen_fd) { + printf("TNT chat server listening on port %d\n", DEFAULT_PORT); + printf("Connect with: telnet localhost %d\n", DEFAULT_PORT); + + while (1) { + struct sockaddr_in client_addr; + socklen_t client_len = sizeof(client_addr); + + int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len); + if (client_fd < 0) { + if (errno == EINTR) continue; + perror("accept"); + continue; + } + + /* Create client structure */ + client_t *client = calloc(1, sizeof(client_t)); + if (!client) { + close(client_fd); + continue; + } + + client->fd = client_fd; + + /* Create thread for client */ + pthread_t thread; + if (pthread_create(&thread, NULL, client_handle_session, client) != 0) { + close(client_fd); + free(client); + continue; + } + + pthread_detach(thread); + } + + return 0; +} diff --git a/src/tui.c b/src/tui.c new file mode 100644 index 0000000..784a36d --- /dev/null +++ b/src/tui.c @@ -0,0 +1,340 @@ +#include "tui.h" +#include "ssh_server.h" +#include "chat_room.h" +#include "utf8.h" +#include +#include + +/* Clear the screen */ +void tui_clear_screen(int fd) { + const char *clear = ANSI_CLEAR ANSI_HOME; + write(fd, clear, strlen(clear)); +} + +/* Render the main screen */ +void tui_render_screen(client_t *client) { + if (!client || !client->connected) return; + + char buffer[8192]; + int pos = 0; + + 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); + + /* Clear and move to top */ + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_CLEAR ANSI_HOME); + + /* Title bar */ + const char *mode_str = (client->mode == MODE_INSERT) ? "INSERT" : + (client->mode == MODE_NORMAL) ? "NORMAL" : + (client->mode == MODE_COMMAND) ? "COMMAND" : "HELP"; + + char title[256]; + snprintf(title, sizeof(title), + " 聊天室 | 在线: %d | 模式: %s | Ctrl+C 退出 | ? 帮助 ", + online, mode_str); + + int title_width = utf8_string_width(title); + int padding = client->width - title_width; + if (padding < 0) padding = 0; + + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_REVERSE "%s", title); + for (int i = 0; i < padding; i++) { + buffer[pos++] = ' '; + } + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_RESET "\n"); + + /* Messages area */ + int msg_height = client->height - 3; + if (msg_height < 1) msg_height = 1; + + /* Calculate which messages to show */ + 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; + } + } + + pthread_rwlock_rdlock(&g_room->lock); + + int end = start + msg_height; + if (end > msg_count) end = msg_count; + + for (int i = start; i < end; i++) { + char msg_line[1024]; + message_format(&g_room->messages[i], msg_line, sizeof(msg_line), client->width); + pos += snprintf(buffer + pos, sizeof(buffer) - pos, "%s\n", msg_line); + } + + pthread_rwlock_unlock(&g_room->lock); + + /* Fill empty lines */ + for (int i = end - start; i < msg_height; i++) { + buffer[pos++] = '\n'; + } + + /* Separator - use box drawing character */ + for (int i = 0; i < client->width && pos < (int)sizeof(buffer) - 10; i++) { + const char *line_char = "─"; /* U+2500 box drawing */ + int len = strlen(line_char); + memcpy(buffer + pos, line_char, len); + pos += len; + } + buffer[pos++] = '\n'; + + /* Status/Input line */ + if (client->mode == MODE_INSERT) { + pos += snprintf(buffer + pos, sizeof(buffer) - pos, "> "); + } else if (client->mode == MODE_NORMAL) { + int total = msg_count; + int scroll_pos = client->scroll_pos + 1; + if (total == 0) scroll_pos = 0; + pos += snprintf(buffer + pos, sizeof(buffer) - pos, + "-- NORMAL -- (%d/%d)", scroll_pos, total); + } else if (client->mode == MODE_COMMAND) { + pos += snprintf(buffer + pos, sizeof(buffer) - pos, + ":%s", client->command_input); + } + + client_send(client, buffer, pos); +} + +/* Render the input line */ +void tui_render_input(client_t *client, const char *input) { + if (!client || !client->connected) return; + + char buffer[2048]; + int input_width = utf8_string_width(input); + + /* Truncate from start if too long */ + char display[1024]; + strncpy(display, input, sizeof(display) - 1); + display[sizeof(display) - 1] = '\0'; + + if (input_width > client->width - 3) { + /* Find where to start displaying */ + int excess = input_width - (client->width - 3); + 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 "> %s", + client->height, 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; + + char buffer[4096]; + int pos = 0; + + /* Clear screen */ + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_CLEAR ANSI_HOME); + + /* Title */ + const char *title = " COMMAND OUTPUT "; + int title_width = strlen(title); + int padding = client->width - title_width; + if (padding < 0) padding = 0; + + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_REVERSE "%s", title); + for (int i = 0; i < padding; i++) { + buffer[pos++] = ' '; + } + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_RESET "\n"); + + /* Command output */ + char *line = strtok(client->command_output, "\n"); + int line_count = 0; + int max_lines = client->height - 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) > client->width) { + utf8_truncate(truncated, client->width); + } + + pos += snprintf(buffer + pos, sizeof(buffer) - pos, "%s\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+C - Exit chat\n" + "\n" + "NORMAL MODE KEYS:\n" + " i - Return to INSERT mode\n" + " : - Enter COMMAND mode\n" + " j - Scroll down (older messages)\n" + " k - Scroll up (newer messages)\n" + " g - Jump to top (oldest)\n" + " G - Jump to bottom (newest)\n" + " ? - Show this help\n" + " Ctrl+C - Exit chat\n" + "\n" + "COMMAND MODE KEYS:\n" + " Enter - Execute command\n" + " ESC - Cancel, return to NORMAL\n" + " Backspace - Delete character\n" + "\n" + "AVAILABLE COMMANDS:\n" + " list, users, who - Show online users\n" + " help, commands - Show available commands\n" + " clear, cls - Clear command output\n" + "\n" + "HELP SCREEN KEYS:\n" + " q, ESC - Close help\n" + " j - Scroll down\n" + " k - Scroll up\n" + " g - Jump to top\n" + " G - Jump to bottom\n" + " e, E - Switch to English\n" + " z, Z - Switch to Chinese\n"; + } else { + return "终端聊天室 - 帮助\n" + "\n" + "操作模式:\n" + " INSERT - 输入和发送消息(默认)\n" + " NORMAL - 浏览消息历史\n" + " COMMAND - 执行命令\n" + "\n" + "INSERT 模式按键:\n" + " ESC - 进入 NORMAL 模式\n" + " Enter - 发送消息\n" + " Backspace - 删除字符\n" + " Ctrl+C - 退出聊天\n" + "\n" + "NORMAL 模式按键:\n" + " i - 返回 INSERT 模式\n" + " : - 进入 COMMAND 模式\n" + " j - 向下滚动(更早的消息)\n" + " k - 向上滚动(更新的消息)\n" + " g - 跳到顶部(最早)\n" + " G - 跳到底部(最新)\n" + " ? - 显示此帮助\n" + " Ctrl+C - 退出聊天\n" + "\n" + "COMMAND 模式按键:\n" + " Enter - 执行命令\n" + " ESC - 取消,返回 NORMAL 模式\n" + " Backspace - 删除字符\n" + "\n" + "可用命令:\n" + " list, users, who - 显示在线用户\n" + " help, commands - 显示可用命令\n" + " clear, cls - 清空命令输出\n" + "\n" + "帮助界面按键:\n" + " q, ESC - 关闭帮助\n" + " j - 向下滚动\n" + " k - 向上滚动\n" + " g - 跳到顶部\n" + " G - 跳到底部\n" + " e, E - 切换到英文\n" + " z, Z - 切换到中文\n"; + } +} + +/* Render the help screen */ +void tui_render_help(client_t *client) { + if (!client || !client->connected) return; + + char buffer[8192]; + int pos = 0; + + /* Clear screen */ + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_CLEAR ANSI_HOME); + + /* Title */ + const char *title = " HELP "; + int title_width = strlen(title); + int padding = client->width - title_width; + if (padding < 0) padding = 0; + + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_REVERSE "%s", title); + for (int i = 0; i < padding; i++) { + buffer[pos++] = ' '; + } + pos += snprintf(buffer + pos, sizeof(buffer) - pos, ANSI_RESET "\n"); + + /* Help content */ + const char *help_text = tui_get_help_text(client->help_lang); + char help_copy[4096]; + 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 = client->height - 2; + int start = client->help_scroll_pos; + 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++) { + pos += snprintf(buffer + pos, sizeof(buffer) - pos, "%s\n", lines[i]); + } + + /* Fill remaining lines */ + for (int i = end - start; i < content_height - 1; i++) { + buffer[pos++] = '\n'; + } + + /* Status line */ + int max_scroll = line_count - content_height + 1; + if (max_scroll < 0) max_scroll = 0; + + pos += snprintf(buffer + pos, sizeof(buffer) - pos, + "-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close", + client->help_scroll_pos + 1, max_scroll + 1); + + client_send(client, buffer, pos); +} diff --git a/src/utf8.c b/src/utf8.c new file mode 100644 index 0000000..8afa505 --- /dev/null +++ b/src/utf8.c @@ -0,0 +1,137 @@ +#include "utf8.h" + +/* Get the number of bytes in a UTF-8 character from its first byte */ +int utf8_byte_length(unsigned char first_byte) { + if ((first_byte & 0x80) == 0) return 1; /* 0xxxxxxx */ + if ((first_byte & 0xE0) == 0xC0) return 2; /* 110xxxxx */ + if ((first_byte & 0xF0) == 0xE0) return 3; /* 1110xxxx */ + if ((first_byte & 0xF8) == 0xF0) return 4; /* 11110xxx */ + return 1; /* Invalid UTF-8, treat as single byte */ +} + +/* Decode a UTF-8 character and return its codepoint */ +uint32_t utf8_decode(const char *str, int *bytes_read) { + const unsigned char *s = (const unsigned char *)str; + uint32_t codepoint = 0; + int len = utf8_byte_length(s[0]); + + *bytes_read = len; + + switch (len) { + case 1: + codepoint = s[0]; + break; + case 2: + codepoint = ((s[0] & 0x1F) << 6) | (s[1] & 0x3F); + break; + case 3: + codepoint = ((s[0] & 0x0F) << 12) | ((s[1] & 0x3F) << 6) | (s[2] & 0x3F); + break; + case 4: + codepoint = ((s[0] & 0x07) << 18) | ((s[1] & 0x3F) << 12) | + ((s[2] & 0x3F) << 6) | (s[3] & 0x3F); + break; + } + + return codepoint; +} + +/* UTF-8 character width calculation for CJK and other wide characters */ +int utf8_char_width(uint32_t codepoint) { + /* ASCII */ + if (codepoint < 0x80) return 1; + + /* CJK Unified Ideographs */ + if ((codepoint >= 0x4E00 && codepoint <= 0x9FFF) || /* CJK Unified */ + (codepoint >= 0x3400 && codepoint <= 0x4DBF) || /* CJK Extension A */ + (codepoint >= 0x20000 && codepoint <= 0x2A6DF) || /* CJK Extension B */ + (codepoint >= 0x2A700 && codepoint <= 0x2B73F) || /* CJK Extension C */ + (codepoint >= 0x2B740 && codepoint <= 0x2B81F) || /* CJK Extension D */ + (codepoint >= 0x2B820 && codepoint <= 0x2CEAF) || /* CJK Extension E */ + (codepoint >= 0xF900 && codepoint <= 0xFAFF) || /* CJK Compatibility */ + (codepoint >= 0x2F800 && codepoint <= 0x2FA1F)) { /* CJK Compat Suppl */ + return 2; + } + + /* Hangul Syllables (Korean) */ + if (codepoint >= 0xAC00 && codepoint <= 0xD7AF) return 2; + + /* Hiragana and Katakana (Japanese) */ + if ((codepoint >= 0x3040 && codepoint <= 0x309F) || /* Hiragana */ + (codepoint >= 0x30A0 && codepoint <= 0x30FF)) { /* Katakana */ + return 2; + } + + /* Fullwidth forms */ + if (codepoint >= 0xFF00 && codepoint <= 0xFFEF) return 2; + + /* Default to single width */ + return 1; +} + +/* Calculate display width of a UTF-8 string */ +int utf8_string_width(const char *str) { + int width = 0; + int bytes_read; + const char *p = str; + + while (*p != '\0') { + uint32_t codepoint = utf8_decode(p, &bytes_read); + width += utf8_char_width(codepoint); + p += bytes_read; + } + + return width; +} + +/* Count the number of UTF-8 characters in a string */ +int utf8_strlen(const char *str) { + int count = 0; + int bytes_read; + const char *p = str; + + while (*p != '\0') { + utf8_decode(p, &bytes_read); + count++; + p += bytes_read; + } + + return count; +} + +/* Truncate string to fit within max_width display characters */ +void utf8_truncate(char *str, int max_width) { + int width = 0; + int bytes_read; + char *p = str; + char *last_valid = str; + + while (*p != '\0') { + uint32_t codepoint = utf8_decode(p, &bytes_read); + int char_width = utf8_char_width(codepoint); + + if (width + char_width > max_width) { + break; + } + + width += char_width; + p += bytes_read; + last_valid = p; + } + + *last_valid = '\0'; +} + +/* Remove last UTF-8 character from string */ +void utf8_remove_last_char(char *str) { + int len = strlen(str); + if (len == 0) return; + + /* Find the start of the last character by walking backwards */ + int i = len - 1; + while (i > 0 && (str[i] & 0xC0) == 0x80) { + i--; /* Continue byte of multi-byte sequence */ + } + + str[i] = '\0'; +}