ascii-chat 0.11.33
Video chat in your terminal
Loading...
Searching...
No Matches
terminal_screen.c
Go to the documentation of this file.
1
25#include "ascii-chat/log/grep.h"
26#include "ascii-chat/log/log.h"
28#include "ascii-chat/common.h"
29#include <stdio.h>
30#include <string.h>
31#include <stdbool.h>
32#include <errno.h>
33#include <limits.h>
34
35// Module-level static frame buffer (reused across renders to avoid malloc per frame)
36static frame_buffer_t *g_frame_buf = NULL;
37
38// Module-level static session log buffer (owned by this module)
39static session_log_buffer_t *g_session_log_buffer = NULL;
40
41// Strip ANSI escape codes from a string (matches map_plain_to_colored_pos logic)
42static void strip_ansi_codes(const char *src, char *dst, size_t dst_size) {
43 if (!src || !dst || dst_size == 0)
44 return;
45
46 size_t pos = 0;
47 while (*src && pos < dst_size - 1) {
48 if (*src == '\x1b') {
49 src++;
50 // Check if there's a next byte
51 if (*src == '\0') {
52 break;
53 }
54 unsigned char next = (unsigned char)*src;
55 if (next == '[') {
56 // CSI sequence: \x1b[...final_byte (where final byte is 0x40-0x7E)
57 src++;
58 while (*src != '\0') {
59 unsigned char c = (unsigned char)*src;
60 src++;
61 if (c >= 0x40 && c <= 0x7E) {
62 break;
63 }
64 }
65 } else if (next >= 0x40 && next <= 0x7E) {
66 // Fe sequence: \x1b + final_byte (e.g., \x1b7, \x1b8)
67 src++;
68 } else if (next == '(' || next == ')' || next == '*' || next == '+') {
69 // Designate character set sequences: \x1b( + charset (3 bytes total)
70 src++; // skip designator
71 if (*src != '\0') {
72 src++; // skip charset ID
73 }
74 } else {
75 // Unknown escape sequence type, try to skip conservatively
76 if (*src != '\0') {
77 src++;
78 }
79 }
80 } else {
81 dst[pos++] = *src++;
82 }
83 }
84 dst[pos] = '\0';
85}
86
87// Cached terminal size (to avoid flooding logs with terminal_get_size errors)
88static terminal_size_t g_cached_term_size = {.rows = 24, .cols = 80};
89static uint64_t g_last_term_size_check_us = UINT64_MAX; // Force check on first call
90#define TERM_SIZE_CHECK_INTERVAL_US US_PER_SEC_INT // Check terminal size max once per second
91
92// Cache of previously rendered log lines for diff-based rendering.
93// Only rewrite lines whose content actually changed.
94#define MAX_CACHED_LINES 256
95static const char *g_prev_log_ptrs[MAX_CACHED_LINES];
96static int g_prev_log_count = 0;
97static int g_prev_total_lines = 0; // Total terminal lines used by logs last frame
98
99static void terminal_screen_clear_cache(void) {
100 memset(g_prev_log_ptrs, 0, sizeof(g_prev_log_ptrs));
101 g_prev_log_count = 0;
102 g_prev_total_lines = 0;
103}
104
105static uint64_t g_render_start_time_ns = 0;
106
108 // Validate config
109 if (!config || !config->render_header) {
110 return;
111 }
112
113 // Track startup timing (first 500ms)
114 static bool first_render = true;
115 if (first_render) {
116 g_render_start_time_ns = platform_get_monotonic_time_us() * 1000;
117 first_render = false;
118 }
119
121 uint64_t elapsed_ms = (now_ns - g_render_start_time_ns) / 1000000;
122
123 // Ensure cursor is visible for log-only UI (splash, status screens)
124 (void)terminal_cursor_show();
125
126 // Update terminal size (cached with 1-second refresh interval)
127 // Always check on first call (when still at default 24x80) to get correct dimensions immediately
129 bool should_check = (g_cached_term_size.rows == 24 && g_cached_term_size.cols == 80) ||
130 (now_us - g_last_term_size_check_us >= TERM_SIZE_CHECK_INTERVAL_US);
131
132 if (should_check) {
133 terminal_size_t new_size = {0};
134
135 // PRIORITY 1: COLUMNS and ROWS environment variables (most reliable, user-controlled)
136 const char *cols_env = SAFE_GETENV("COLUMNS");
137 const char *rows_env = SAFE_GETENV("ROWS");
138 if (cols_env && rows_env) {
139 char *endptr;
140 errno = 0;
141 long cols_val = strtol(cols_env, &endptr, 10);
142 if (*endptr == '\0' && errno == 0 && cols_val > 0 && cols_val <= INT_MAX) {
143 int cols = (int)cols_val;
144 errno = 0;
145 long rows_val = strtol(rows_env, &endptr, 10);
146 if (*endptr == '\0' && errno == 0 && rows_val > 0 && rows_val <= INT_MAX) {
147 int rows = (int)rows_val;
148 new_size.cols = cols;
149 new_size.rows = rows;
150 } else {
151 log_error("terminal_screen_render: Invalid ROWS value: %s", rows_env);
152 }
153 } else {
154 log_error("terminal_screen_render: Invalid COLUMNS value: %s", cols_env);
155 }
156 }
157
158 // PRIORITY 2: Options-provided width/height (explicit user settings)
159 if (new_size.cols == 0 || new_size.rows == 0) {
160 const options_t *opts = options_get();
161 if (opts && opts->width > 0 && opts->height > 0) {
162 new_size.cols = opts->width;
163 new_size.rows = opts->height;
164 }
165 }
166
167 // PRIORITY 3: Terminal auto-detection
168 if (new_size.cols == 0 || new_size.rows == 0) {
169 if (terminal_get_size(&new_size) != ASCIICHAT_OK) {
170 // If terminal detection fails, use defaults
171 new_size.cols = 80;
172 new_size.rows = 24;
173 }
174 }
175
176 if (new_size.cols > 0 && new_size.rows > 0) {
177 g_cached_term_size = new_size;
178 }
179 g_last_term_size_check_us = now_us;
180 }
181
182 bool grep_entering = log_search_is_entering();
183
184 // When transitioning out of grep mode, clear the log cache so all logs are redrawn
185 // (grep mode may have shown filtered logs, need to refresh for normal view)
186 static bool last_grep_state = false;
187 if (last_grep_state && !grep_entering) {
188 terminal_screen_clear_cache();
189 }
190
191 // Also clear cache when pattern changes while still in grep mode (e.g., user backspaces to empty)
192 if (grep_entering && log_search_needs_rerender()) {
193 terminal_screen_clear_cache();
194 }
195
196 last_grep_state = grep_entering;
197
198 // Allocate or reuse frame buffer (module-level static to avoid malloc per frame)
199 if (!g_frame_buf) {
200 g_frame_buf = frame_buffer_create(g_cached_term_size.rows, g_cached_term_size.cols);
201 if (!g_frame_buf) {
202 return; // Allocation failed
203 }
204 }
205
206 frame_buffer_reset(g_frame_buf);
207
208 if (!grep_entering) {
209 // Redraw in place; frame_buffer_flush erases each rendered line without
210 // exposing a full-screen clear between frames.
211 frame_buffer_cursor_home(g_frame_buf);
212 } else {
213 // Grep mode: overwrite in place, never clear whole screen
214 frame_buffer_cursor_home(g_frame_buf);
215 }
216
217 // Render fixed header via callback (writes into frame buffer)
218 config->render_header(g_frame_buf, g_cached_term_size, config->user_data);
219
220 // If logs are disabled, flush and return
221 if (!config->show_logs) {
222 frame_buffer_flush(g_frame_buf);
223 return;
224 }
225
226 // Measure actual header height by pre-rendering and counting display lines
227 int actual_header_height = config->fixed_header_lines;
228
229 if (config->render_header) {
230 // Pre-render header to a temporary buffer to measure its display height
231 frame_buffer_t *temp_buf = frame_buffer_create(config->fixed_header_lines + 10, g_cached_term_size.cols);
232 if (temp_buf) {
233 config->render_header(temp_buf, g_cached_term_size, config->user_data);
234
235 // Measure display height of the rendered header
236 // The buffer contains ANSI codes + text. We count newlines and account for wrapped lines.
237 int measured_height = 0;
238 const char *content = frame_buffer_get_content(temp_buf);
239 size_t buf_len = frame_buffer_get_length(temp_buf);
240 const char *buf_end = content + buf_len;
241 const char *line_start = content;
242
243 while (content < buf_end) {
244 if (*content == '\n') {
245 // Found end of line - measure this line's display height
246 size_t line_len = content - line_start;
247 char line_buf[1024];
248 if (line_len < sizeof(line_buf)) {
249 memcpy(line_buf, line_start, line_len);
250 line_buf[line_len] = '\0';
251
252 int line_height = display_height(line_buf, g_cached_term_size.cols);
253 if (line_height <= 0)
254 line_height = 1;
255 measured_height += line_height;
256 }
257 line_start = content + 1;
258 }
259 content++;
260 }
261
262 if (measured_height > 0) {
263 actual_header_height = measured_height;
264 }
265 frame_buffer_destroy(temp_buf);
266 }
267 }
268
269 // Calculate log area: total rows - actual header height - 1 (prevent scroll)
270 // When grep search is open, subtract additional row to prevent header bump
271 int log_area_rows = g_cached_term_size.rows - actual_header_height - (grep_entering ? 2 : 1);
272
273 if (log_area_rows <= 0) {
274 // Terminal too small for logs, flush header and return
275 frame_buffer_flush(g_frame_buf);
276 if (grep_entering) {
277 log_search_render_input_line(g_cached_term_size.cols);
278 }
279 return;
280 }
281
282 // When grep input is active, reserve 1 row for the grep input line at bottom.
283 // Subtract 1 from log_area_rows to prevent logs from overlapping the grep input.
284 int renderable_log_rows = grep_entering ? (log_area_rows - 1) : log_area_rows;
285
286 // Fetch and filter logs
287 session_log_entry_t *log_entries = NULL;
288 size_t log_count = 0;
289
290 if (log_search_is_active()) {
291 asciichat_error_t result = log_search_gather_and_filter_logs(&log_entries, &log_count);
292 if (result != ASCIICHAT_OK || !log_entries) {
294 if (log_entries) {
295 log_count = session_log_buffer_get_recent(g_session_log_buffer, log_entries, SESSION_LOG_BUFFER_SIZE);
296 }
297 }
298 } else {
299 // Get logs from in-memory buffer
300 session_log_entry_t *buffer_entries =
302 if (buffer_entries) {
303 size_t buffer_count =
304 session_log_buffer_get_recent(g_session_log_buffer, buffer_entries, SESSION_LOG_BUFFER_SIZE);
305 log_entries = buffer_entries;
306 log_count = buffer_count;
307 }
308 }
309
310 // Display logs - most recent at bottom, oldest scrolled off top.
311 // Calculate a stable window: show logs that fit in available space, newest last.
312 // This ensures smooth scrolling as new logs arrive (no jumping viewport).
313 // Use renderable_log_rows when in grep mode to account for grep input line.
314 // CRITICAL: account for multi-line logs via display_height() in pre-selection.
315 // Without this, logs overflow and push the header off screen.
316 int first_log_to_display = log_count; // Start past the end (no logs selected)
317 int total_lines_used = 0;
318 int available_rows = grep_entering ? renderable_log_rows : log_area_rows;
319
320 // Work backwards from newest to oldest log, selecting which fit
321 for (int i = (int)log_count - 1; i >= 0; i--) {
322 int lines_for_msg = display_height(log_entries[i].message, g_cached_term_size.cols);
323 if (lines_for_msg <= 0) {
324 lines_for_msg = 1;
325 }
326 // Check if adding this log would exceed available space
327 if (total_lines_used + lines_for_msg <= available_rows) {
328 total_lines_used += lines_for_msg;
329 first_log_to_display = i; // This is the first (oldest) log we'll display
330 }
331 // Continue checking older logs - they might be shorter and still fit
332 }
333
334 // If no logs fit, show nothing (just padding)
335 if (first_log_to_display == (int)log_count) {
336 first_log_to_display = log_count; // Will result in 0 logs to display
337 }
338
339 int logs_to_display = log_count - first_log_to_display;
340
341 // Log detailed info about what logs are being displayed
342 int logs_displayed_count = logs_to_display;
343 if (elapsed_ms < 500) {
344 log_dev_every(1 * NS_PER_SEC_INT, "[STARTUP_FRAME] t=%llu log_count=%zu first=%d display=%d area=%d",
345 (unsigned long long)elapsed_ms, log_count, first_log_to_display, logs_displayed_count, log_area_rows);
346 } else {
348 "[HEIGHT_CALC] log_count=%zu first_log_to_display=%d logs_displayed=%d "
349 "renderable_log_rows=%d",
350 log_count, first_log_to_display, logs_displayed_count, renderable_log_rows);
351 }
352
353 if (!grep_entering) {
354 // Normal mode: accumulate everything (header + logs + blank lines) into frame buffer,
355 // then flush atomically. This prevents real-time logs from appearing after the frame,
356 // which would cause the display to scroll and push the header up.
357
358 // Output logs to frame buffer, counting actual display lines (accounting for wrapping)
359 int terminal_lines_used = 0;
360 for (int i = first_log_to_display; i < (int)log_count && terminal_lines_used < log_area_rows; i++) {
361 const char *msg = log_entries[i].message;
362
363 // Calculate how many display lines this message will consume when wrapped
364 // Uses display_height() which handles ANSI codes and UTF-8 properly
365 int lines_for_this_msg = display_height(msg, g_cached_term_size.cols);
366 if (lines_for_this_msg <= 0) {
367 lines_for_this_msg = 1;
368 }
369
370 // Only output if it fits in remaining space
371 if (terminal_lines_used + lines_for_this_msg <= log_area_rows) {
372 frame_buffer_printf(g_frame_buf, "%s\n", msg);
373 terminal_lines_used += lines_for_this_msg;
374 }
375 }
376
377 // Pad remaining log area with blank lines to maintain fixed frame height.
378 // This ensures every frame has exactly the same height on screen,
379 // preventing flicker caused by dynamic frame height changes.
380 while (terminal_lines_used < log_area_rows) {
381 frame_buffer_printf(g_frame_buf, "\n");
382 terminal_lines_used++;
383 }
384
385 // Flush entire frame (header + logs + blank lines) in one atomic write
386 frame_buffer_flush(g_frame_buf);
387 } else {
388 // Grep mode: render logs with frame buffer (same as normal mode)
389 // Just apply grep highlighting to visible logs
390
391 int terminal_lines_used = 0;
392 for (int i = first_log_to_display; i < (int)log_count && terminal_lines_used < log_area_rows; i++) {
393 const char *original_msg = log_entries[i].message;
394 const char *msg = original_msg;
395
396 // Strip ANSI codes first to match against plain text
397 char plain_text[SESSION_LOG_LINE_MAX] = {0};
398 strip_ansi_codes(original_msg, plain_text, sizeof(plain_text));
399
400 // Apply highlighting if match found in plain text
401 size_t match_start = 0, match_len = 0;
402 if (log_search_get_match_info(plain_text, &match_start, &match_len) && match_len > 0) {
403 // Validate match is within bounds
404 size_t plain_len = strlen(plain_text);
405 if (match_start < plain_len && (match_start + match_len) <= plain_len) {
406 const char *highlighted = grep_highlight_colored(original_msg, plain_text, match_start, match_len);
407 if (highlighted && highlighted[0] != '\0') {
408 msg = highlighted;
409 }
410 }
411 }
412
413 int msg_display_width = display_width(msg);
414 if (msg_display_width < 0) {
415 msg_display_width = (int)strlen(msg);
416 }
417 int lines_for_this_msg = 1;
418 if (g_cached_term_size.cols > 0 && msg_display_width > g_cached_term_size.cols) {
419 lines_for_this_msg = (msg_display_width + g_cached_term_size.cols - 1) / g_cached_term_size.cols;
420 }
421
422 if (terminal_lines_used + lines_for_this_msg <= log_area_rows) {
423 frame_buffer_printf(g_frame_buf, "%s\n", msg);
424 terminal_lines_used += lines_for_this_msg;
425 }
426 }
427
428 // Pad remaining log area with blank lines
429 while (terminal_lines_used < log_area_rows) {
430 frame_buffer_printf(g_frame_buf, "\n");
431 terminal_lines_used++;
432 }
433
434 // Flush frame buffer before rendering grep UI
435 frame_buffer_flush(g_frame_buf);
436
437 // Grep input line UI (position cursor at bottom and display "/" prompt with pattern)
438 // Get the search pattern under mutex protection
439 mutex_t *grep_mutex = log_search_get_mutex();
440 if (grep_mutex && g_cached_term_size.rows > 0) {
441 mutex_lock(grep_mutex);
442 int pattern_len = log_search_get_input_len();
443 const char *pattern = log_search_get_input_buffer();
444 int cursor_pos = log_search_get_cursor_position();
445
446 // Render grep input line at bottom of screen
447 printf("\x1b[%d;1H/", g_cached_term_size.rows);
448 if (pattern_len > 0 && pattern) {
449 printf("%.*s", pattern_len, pattern);
450 }
451
452 // Position cursor at current edit position
453 int cursor_offset = pattern_len - cursor_pos;
454 if (cursor_offset > 0) {
455 printf("\x1b[%dD", cursor_offset);
456 }
457 fflush(stdout);
458
459 mutex_unlock(grep_mutex);
460 }
461 }
462
463 SAFE_FREE(log_entries);
464}
465
467 if (g_frame_buf) {
468 frame_buffer_destroy(g_frame_buf);
469 g_frame_buf = NULL;
470 }
471}
472
474 if (g_session_log_buffer) {
475 return g_session_log_buffer; // Already initialized
476 }
477
478 g_session_log_buffer = session_log_buffer_create();
479 return g_session_log_buffer;
480}
481
483 if (g_session_log_buffer) {
484 session_log_buffer_destroy(g_session_log_buffer);
485 g_session_log_buffer = NULL;
486 }
487}
488
490 if (g_session_log_buffer) {
491 session_log_buffer_clear(g_session_log_buffer);
492 }
493}
494
496 return g_session_log_buffer;
497}
498
Platform abstraction layer umbrella header providing unified cross-platform API.
⚙️ Common definitions, error codes, macros, and types shared throughout the application
Cross-platform error handling utilities.
void frame_buffer_cursor_home(frame_buffer_t *buf)
Append cursor home escape code (\033[H)
void frame_buffer_set_screen_output_fd(int fd)
Set output file descriptor for terminal screen (splash/status) rendering.
void frame_buffer_reset(frame_buffer_t *buf)
Clear buffer contents while keeping allocation.
void frame_buffer_destroy(frame_buffer_t *buf)
Destroy a frame buffer.
void frame_buffer_flush(frame_buffer_t *buf)
Flush the entire buffer to stdout in one atomic write.
size_t frame_buffer_get_length(const frame_buffer_t *buf)
Get the current content length of the frame buffer.
const char * frame_buffer_get_content(const frame_buffer_t *buf)
Get a pointer to the buffer content.
frame_buffer_t * frame_buffer_create(int rows, int cols)
Create a new frame buffer.
void frame_buffer_printf(frame_buffer_t *buf, const char *fmt,...)
Printf-style append to the buffer.
Buffered terminal frame rendering.
const char * grep_highlight_colored(const char *colored_text, const char *plain_text, size_t match_start, size_t match_len)
Highlight matches in colored text while preserving original colors.
Definition grep.c:755
#define SAFE_FREE(ptr)
Definition common.h:376
#define SAFE_MALLOC(size, cast)
Definition common.h:264
#define SAFE_GETENV(name)
Definition common.h:434
unsigned long long uint64_t
Definition common.h:59
asciichat_error_t
Error and exit codes - unified status values (0-255)
Definition error_codes.h:49
@ ASCIICHAT_OK
Definition error_codes.h:51
#define log_error(...)
Log an ERROR message.
Definition log/log.h:587
#define NS_PER_SEC_INT
Definition time.h:157
const options_t * options_get(void)
Get current options (lock-free read)
Definition rcu.c:496
asciichat_error_t terminal_get_size(terminal_size_t *size)
Get terminal size.
#define mutex_lock(mutex)
Lock a mutex (with debug tracking in debug builds)
uint64_t platform_get_monotonic_time_us(void)
Get monotonic time in microseconds.
int errno
#define mutex_unlock(mutex)
Unlock a mutex (with debug tracking in debug builds)
asciichat_error_t terminal_cursor_show(void)
Show terminal cursor.
int display_width(const char *text)
Calculate the visible display width of text with ANSI escape codes.
int display_height(const char *text, int terminal_width)
Calculate how many display lines text will consume when wrapped.
⚙️ Unified options parsing system for ascii-chat with builder pattern and lock-free access
Thread-safe circular log buffer for session screens (status, splash)
🖥️ Display Width and Terminal Centering Utilities
Application limits and constraints.
📝 Logging API with multiple log levels and terminal output control
#define log_debug_every(interval_us, fmt,...)
Rate-limited DEBUG logging.
Definition log/log.h:702
#define log_dev_every(interval_us, fmt,...)
Rate-limited DEV logging.
Definition log/log.h:699
Cross-platform mutex interface for ascii-chat.
session_log_buffer_t * session_log_buffer_create(void)
Create a new session log buffer instance.
void session_log_buffer_clear(session_log_buffer_t *buf)
Clear all log entries from buffer.
size_t session_log_buffer_get_recent(session_log_buffer_t *buf, session_log_entry_t *out_entries, size_t max_count)
Get recent log entries from buffer.
void session_log_buffer_destroy(session_log_buffer_t *buf)
Cleanup session log buffer instance.
void * log_search_get_mutex(void)
Get the mutex protecting grep state.
Definition search.c:936
bool log_search_needs_rerender(void)
Check if screen needs immediate re-render.
Definition search.c:884
bool log_search_is_entering(void)
Check if currently in input mode (typing pattern)
Definition search.c:385
int log_search_get_input_len(void)
Get current input buffer length (must hold mutex)
Definition search.c:940
const char * log_search_get_input_buffer(void)
Get current input buffer content (must hold mutex)
Definition search.c:948
bool log_search_is_active(void)
Check if filtering is active.
Definition search.c:390
bool log_search_get_match_info(const char *message, size_t *out_match_start, size_t *out_match_len)
Get match info for highlighting a log message.
Definition search.c:729
int log_search_get_cursor_position(void)
Get current cursor position in input buffer (must hold mutex)
Definition search.c:944
void log_search_render_input_line(int width)
Render grep input line at bottom of screen.
Definition search.c:691
asciichat_error_t log_search_gather_and_filter_logs(session_log_entry_t **out_entries, size_t *out_count)
Gather and filter logs for display.
Definition search.c:530
Interactive search filtering for terminal screens.
ClangTool/LibTooling compatibility shim for stdbool.h.
Frame buffer structure.
Mutex type (POSIX: pthread_mutex_t with debug tracking)
Consolidated options structure.
int height
Terminal height in characters (int for OPTION_TYPE_INT compat)
int width
Terminal width in characters (int for OPTION_TYPE_INT compat)
Internal circular buffer structure.
Single log entry with sequence number for ordering.
char message[1024]
Formatted log message (may include ANSI colors)
Configuration for terminal screen rendering.
terminal_screen_header_fn render_header
Callback to draw header content.
int fixed_header_lines
How many lines the header takes (e.g., 4 for status, 8 for splash)
bool show_logs
Whether to show log feed below header.
void * user_data
Passed to render_header callback.
Terminal size structure.
Definition terminal.h:163
int cols
Number of columns (width) in terminal.
Definition terminal.h:165
int rows
Number of rows (height) in terminal.
Definition terminal.h:164
Cross-platform system functions interface for ascii-chat.
void terminal_screen_log_destroy(void)
Standard log cleanup for terminal screens.
#define MAX_CACHED_LINES
#define TERM_SIZE_CHECK_INTERVAL_US
session_log_buffer_t * terminal_screen_get_log_buffer(void)
Get the current session log buffer.
void terminal_screen_render(const terminal_screen_config_t *config)
Render a terminal screen with fixed header and scrolling logs.
void terminal_screen_cleanup(void)
Clean up terminal screen resources.
void terminal_screen_log_clear(void)
Clear buffered logs for terminal screens.
session_log_buffer_t * terminal_screen_log_init(void)
Initialize session log buffer for terminal screens.
void terminal_screen_set_output_fd(int fd)
Set output file descriptor for terminal screens (splash/status)
Reusable "fixed header + scrolling logs" terminal screen abstraction.
⏱️ High-precision timing utilities using sokol_time.h and uthash