ascii-chat 0.8.38
Real-time terminal-based video chat with ASCII art conversion
Loading...
Searching...
No Matches
terminal_screen.c File Reference

Reusable "fixed header + scrolling logs" terminal screen abstraction. More...

Go to the source code of this file.

Macros

#define TERM_SIZE_CHECK_INTERVAL_US   US_PER_SEC_INT
 
#define MAX_CACHED_LINES   256
 

Functions

void terminal_screen_render (const terminal_screen_config_t *config)
 

Detailed Description

Reusable "fixed header + scrolling logs" terminal screen abstraction.

This module extracts the common rendering logic from splash.c and server_status.c into a unified, testable abstraction.

Design:

  • Callback-based architecture for header content generation
  • Automatic log scrolling with line wrapping
  • Thread-safe log buffer management
  • Terminal resize handling
  • Color scheme integration

Definition in file terminal_screen.c.

Macro Definition Documentation

◆ MAX_CACHED_LINES

#define MAX_CACHED_LINES   256

Definition at line 85 of file terminal_screen.c.

◆ TERM_SIZE_CHECK_INTERVAL_US

#define TERM_SIZE_CHECK_INTERVAL_US   US_PER_SEC_INT

Definition at line 81 of file terminal_screen.c.

Function Documentation

◆ terminal_screen_render()

void terminal_screen_render ( const terminal_screen_config_t *  config)

Definition at line 96 of file terminal_screen.c.

96 {
97 // Validate config
98 if (!config || !config->render_header) {
99 return;
100 }
101
102 // Update terminal size (cached with 1-second refresh interval)
103 uint64_t now_us = platform_get_monotonic_time_us();
104 if (now_us - g_last_term_size_check_us >= TERM_SIZE_CHECK_INTERVAL_US) {
105 terminal_size_t new_size;
106 if (terminal_get_size(&new_size) == ASCIICHAT_OK) {
107 g_cached_term_size = new_size;
108 }
109 g_last_term_size_check_us = now_us;
110 }
111
112 bool grep_entering = interactive_grep_is_entering();
113
114 // When transitioning out of grep mode, clear the log cache so all logs are redrawn
115 // (grep mode may have shown filtered logs, need to refresh for normal view)
116 static bool last_grep_state = false;
117 if (last_grep_state && !grep_entering) {
118 terminal_screen_clear_cache();
119 }
120
121 // Also clear cache when pattern changes while still in grep mode (e.g., user backspaces to empty)
122 if (grep_entering && interactive_grep_needs_rerender()) {
123 terminal_screen_clear_cache();
124 }
125
126 last_grep_state = grep_entering;
127
128 if (!grep_entering) {
129 // Normal mode: clear and redraw (no flicker concern without grep)
130 terminal_clear_screen();
131 terminal_cursor_home(STDOUT_FILENO);
132 } else {
133 // Grep mode: overwrite in place, never clear whole screen
134 terminal_cursor_home(STDOUT_FILENO);
135 }
136
137 // Render fixed header via callback
138 config->render_header(g_cached_term_size, config->user_data);
139
140 // If logs are disabled, we're done
141 if (!config->show_logs) {
142 fflush(stdout);
143 return;
144 }
145
146 // Calculate log area: total rows - header lines - 1 (prevent scroll)
147 int log_area_rows = g_cached_term_size.rows - config->fixed_header_lines - 1;
148
149 if (log_area_rows <= 0) {
150 if (grep_entering) {
151 interactive_grep_render_input_line(g_cached_term_size.cols);
152 }
153 fflush(stdout);
154 return;
155 }
156
157 // When grep input is active, use full log area for logs since grep input
158 // will be rendered on the last row (which is normally reserved for preventing scroll).
159 // This maximizes vertical space usage while keeping grep input at the bottom.
160 int renderable_log_rows = log_area_rows;
161
162 // Fetch and filter logs
163 session_log_entry_t *log_entries = NULL;
164 size_t log_count = 0;
165
167 asciichat_error_t result = interactive_grep_gather_and_filter_logs(&log_entries, &log_count);
168 if (result != ASCIICHAT_OK || !log_entries) {
169 log_entries = SAFE_MALLOC(SESSION_LOG_BUFFER_SIZE * sizeof(session_log_entry_t), session_log_entry_t *);
170 if (log_entries) {
171 log_count = session_log_buffer_get_recent(log_entries, SESSION_LOG_BUFFER_SIZE);
172 }
173 }
174 } else {
175 // Get logs from in-memory buffer
176 session_log_entry_t *buffer_entries =
177 SAFE_MALLOC(SESSION_LOG_BUFFER_SIZE * sizeof(session_log_entry_t), session_log_entry_t *);
178 if (buffer_entries) {
179 size_t buffer_count = session_log_buffer_get_recent(buffer_entries, SESSION_LOG_BUFFER_SIZE);
180 log_entries = buffer_entries;
181 log_count = buffer_count;
182 }
183 }
184
185 // Calculate which logs fit (working backwards from most recent).
186 // Use renderable_log_rows so that entering grep mode doesn't change
187 // which logs are selected - only the bottom line changes from log to input.
188 int total_lines_needed = 0;
189 int first_log_to_display = (log_count > 0) ? (int)log_count - 1 : 0;
190
191 for (int i = (int)log_count - 1; i >= 0; i--) {
192 const char *msg = log_entries[i].message;
193 int msg_display_width = display_width(msg);
194 if (msg_display_width < 0) {
195 msg_display_width = (int)strlen(msg);
196 }
197
198 int lines_for_this_log = 1;
199 if (g_cached_term_size.cols > 0 && msg_display_width > g_cached_term_size.cols) {
200 lines_for_this_log = (msg_display_width + g_cached_term_size.cols - 1) / g_cached_term_size.cols;
201 }
202
203 if (total_lines_needed + lines_for_this_log > renderable_log_rows) {
204 first_log_to_display = i + 1;
205 break;
206 }
207
208 total_lines_needed += lines_for_this_log;
209 first_log_to_display = i;
210 }
211
212 if (!grep_entering) {
213 // Normal mode: just print everything (screen was cleared)
214 for (int i = first_log_to_display; i < (int)log_count; i++) {
215 fprintf(stdout, "%s\n", log_entries[i].message);
216 }
217
218 // Fill remaining lines (use full log_area_rows)
219 int remaining = log_area_rows - total_lines_needed;
220 for (int i = 0; i < remaining; i++) {
221 fprintf(stdout, "\n");
222 }
223 } else {
224 // Grep mode: diff-based rendering. Only rewrite lines that changed.
225 // Logs fill renderable_log_rows; the last row is the `/` input line.
226
227 int log_idx = 0;
228 int lines_used = 0;
229
230 for (int i = first_log_to_display; i < (int)log_count; i++) {
231 const char *original_msg = log_entries[i].message;
232 const char *msg = original_msg;
233
234 // Strip ANSI codes first to match against plain text
235 char plain_text[SESSION_LOG_LINE_MAX] = {0};
236 strip_ansi_codes(original_msg, plain_text, sizeof(plain_text));
237
238 // Apply highlighting if match found in plain text
239 size_t match_start = 0, match_len = 0;
240 if (interactive_grep_get_match_info(plain_text, &match_start, &match_len) && match_len > 0) {
241 // Validate match is within bounds
242 size_t plain_len = strlen(plain_text);
243 if (match_start < plain_len && (match_start + match_len) <= plain_len) {
244 const char *highlighted = grep_highlight_colored(original_msg, plain_text, match_start, match_len);
245 if (highlighted && highlighted[0] != '\0') {
246 msg = highlighted;
247 }
248 }
249 }
250
251 int msg_display_width = display_width(msg);
252 if (msg_display_width < 0) {
253 msg_display_width = (int)strlen(msg);
254 }
255 int lines_for_this = 1;
256 if (g_cached_term_size.cols > 0 && msg_display_width > g_cached_term_size.cols) {
257 lines_for_this = (msg_display_width + g_cached_term_size.cols - 1) / g_cached_term_size.cols;
258 }
259
260 // When logs are filtered (grep active), they're in a different order than before.
261 // Only use cached pointers when NOT filtering to avoid cache misses on every frame.
262 // This prevents the "spam" effect where all logs redraw constantly during grep searches.
263 bool same_as_before = false;
265 same_as_before = (log_idx < g_prev_log_count && g_prev_log_ptrs[log_idx] == original_msg);
266 }
267
268 if (same_as_before) {
269 // Content unchanged - skip past it without rewriting.
270 // Still need to reset colors to prevent them from leaking to next output.
271 if (lines_for_this == 1) {
272 fprintf(stdout, "\x1b[0m\n");
273 } else {
274 fprintf(stdout, "\x1b[0m\x1b[%dB", lines_for_this);
275 }
276 } else {
277 // Content changed - overwrite and clear tail.
278 // Reset both foreground and background colors to prevent color bleeding
279 // to the next line (important for grep UI which follows).
280 fprintf(stdout, "%s\x1b[0m\x1b[K\n", msg);
281 }
282
283 if (log_idx < MAX_CACHED_LINES) {
284 g_prev_log_ptrs[log_idx] = original_msg;
285 }
286 log_idx++;
287 lines_used += lines_for_this;
288 }
289
290 // Fill blank lines up to renderable_log_rows. If there's one line of space left,
291 // render one more partial log (truncated to fit terminal width) instead of blank line.
292 int remaining = renderable_log_rows - lines_used;
293
294 if (remaining >= 1 && first_log_to_display > 0) {
295 // Render one more log line above the displayed ones, truncated to fit terminal width
296 int prev_idx = first_log_to_display - 1;
297 const char *prev_msg = log_entries[prev_idx].message;
298
299 int prev_width = display_width(prev_msg);
300 if (prev_width < 0) {
301 prev_width = (int)strlen(prev_msg);
302 }
303
304 if (prev_width > g_cached_term_size.cols) {
305 // Truncate to fit: progressively test shorter substrings until one fits
306 int target_width = g_cached_term_size.cols - 3; // Reserve space for ellipsis
307 if (target_width <= 0) {
308 fprintf(stdout, "...\x1b[K\n");
309 } else {
310 size_t src_len = strlen(prev_msg);
311 bool found = false;
312
313 for (size_t truncate_at = src_len; truncate_at > 0; truncate_at--) {
314 char test_buf[SESSION_LOG_LINE_MAX];
315 SAFE_STRNCPY(test_buf, prev_msg, truncate_at);
316 test_buf[truncate_at] = '\0';
317
318 int test_width = display_width(test_buf);
319 if (test_width < 0) {
320 test_width = (int)strlen(test_buf);
321 }
322
323 if (test_width <= target_width) {
324 // Found a length that fits
325 fprintf(stdout, "%s...\x1b[K\n", test_buf);
326 found = true;
327 break;
328 }
329 }
330
331 if (!found) {
332 fprintf(stdout, "...\x1b[K\n");
333 }
334 }
335 } else {
336 // Fits without truncation
337 fprintf(stdout, "%s\x1b[K\n", prev_msg);
338 }
339
340 remaining--;
341 }
342
343 // Fill remaining blank lines with color reset to prevent color bleed
344 for (int i = 0; i < remaining; i++) {
345 fprintf(stdout, "\x1b[0m\x1b[K\n");
346 }
347
348 g_prev_log_count = log_idx;
349 g_prev_total_lines = lines_used;
350
351 // Flush buffered output before rendering grep UI to ensure correct order
352 fflush(stdout);
353
354 // Atomic grep UI rendering: combine cursor positioning and input line into
355 // a single write to prevent log output from interrupting the escape sequences.
356 // This prevents the race condition where logs appear between the cursor
357 // positioning command and the grep input line rendering.
358 char grep_ui_buffer[512];
359 int pos = snprintf(grep_ui_buffer, sizeof(grep_ui_buffer), "\x1b[%d;1H\x1b[0m\x1b[K",
360 g_cached_term_size.rows);
361
362 // Validate snprintf succeeded and produced expected output
363 if (pos > 0 && pos < (int)sizeof(grep_ui_buffer) - 256) {
364 // Lock while reading grep state to ensure atomic render
365 // Get the search pattern under mutex protection
366 mutex_t *grep_mutex = interactive_grep_get_mutex();
367 if (grep_mutex) {
368 mutex_lock(grep_mutex);
369 int pattern_len = interactive_grep_get_input_len();
370 const char *pattern = interactive_grep_get_input_buffer();
371
372 if (pattern_len > 0 && pattern) {
373 int remaining = snprintf(grep_ui_buffer + pos, sizeof(grep_ui_buffer) - (size_t)pos,
374 "/%.*s", pattern_len, pattern);
375 if (remaining > 0 && remaining < (int)sizeof(grep_ui_buffer) - (int)pos) {
376 pos += remaining;
377 }
378 } else {
379 // No pattern yet - just output the slash
380 if (pos + 1 < (int)sizeof(grep_ui_buffer)) {
381 grep_ui_buffer[pos++] = '/';
382 }
383 }
384 mutex_unlock(grep_mutex);
385 }
386 }
387
388 // Write entire grep UI (cursor positioning + input) in single operation
389 if (pos > 0 && pos <= (int)sizeof(grep_ui_buffer)) {
390 platform_write_all(STDOUT_FILENO, grep_ui_buffer, (size_t)pos);
391 }
392 }
393
394 SAFE_FREE(log_entries);
395}
size_t platform_write_all(int fd, const void *buf, size_t count)
Write all data to file descriptor with automatic retry on transient errors.
Definition abstraction.c:39
const char * grep_highlight_colored(const char *colored_text, const char *plain_text, size_t match_start, size_t match_len)
Definition grep.c:806
bool interactive_grep_is_entering(void)
const char * interactive_grep_get_input_buffer(void)
asciichat_error_t interactive_grep_gather_and_filter_logs(session_log_entry_t **out_entries, size_t *out_count)
bool interactive_grep_is_active(void)
bool interactive_grep_needs_rerender(void)
void * interactive_grep_get_mutex(void)
void interactive_grep_render_input_line(int width)
int interactive_grep_get_input_len(void)
bool interactive_grep_get_match_info(const char *message, size_t *out_match_start, size_t *out_match_len)
int display_width(const char *text)
asciichat_error_t terminal_get_size(terminal_size_t *size)
uint64_t platform_get_monotonic_time_us(void)
size_t session_log_buffer_get_recent(session_log_entry_t *out_entries, size_t max_count)
#define MAX_CACHED_LINES
#define TERM_SIZE_CHECK_INTERVAL_US

References display_width(), grep_highlight_colored(), interactive_grep_gather_and_filter_logs(), interactive_grep_get_input_buffer(), interactive_grep_get_input_len(), interactive_grep_get_match_info(), interactive_grep_get_mutex(), interactive_grep_is_active(), interactive_grep_is_entering(), interactive_grep_needs_rerender(), interactive_grep_render_input_line(), MAX_CACHED_LINES, platform_get_monotonic_time_us(), platform_write_all(), session_log_buffer_get_recent(), TERM_SIZE_CHECK_INTERVAL_US, and terminal_get_size().

Referenced by server_status_display(), and server_status_display_interactive().