ascii-chat
Binary v0.15.83-53-gb711d8720
Library lib/v0.3.2-701-gb711d8720
Video chat in your terminal
Loading...
Searching...
No Matches
tui/controller.c
Go to the documentation of this file.
1
2/* Storage capacities in elements; text buffers include the terminating NUL. */
3#define TUI_CONTROLLER_LAST_TITLE_BUFFER_SIZE 512
4#define TUI_CONTROLLER_TITLE_BUFFER_SIZE 512
5#define TUI_CONTROLLER_BUFFER_BUFFER_SIZE 16384
6
7/* Existing buffer budgets in bytes, including NUL for text storage. */
8#define TUI_MEDIA_SNAPSHOT_CAPACITY 8
9#define TUI_LAYER_CAPACITY 3
10
11#include <ascii-chat/otel.h>
12#include <ascii-chat/ui/text.h>
18#include <ascii-chat/tui/sync.h>
26#include <libplatform/atomic.h>
30#include <ascii-chat/common.h>
32#include <libplatform/compiler.h>
33#include <libplatform/system.h>
34#include <libplatform/terminal.h>
35#include <libplatform/thread.h>
36#include <libplatform/cond.h>
37#include <stdatomic.h>
38#include <libplatform/lifecycle.h>
42#include <string.h>
43#include <stdarg.h>
44#include <stdio.h>
51
52typedef struct {
53 _Atomic unsigned references;
54 int fd;
58
60
68typedef struct {
69 _Atomic unsigned references;
73 _Alignas(max_align_t) char data[];
77
84
85typedef struct {
86 int fd;
87 bool media;
88 terminal_size_t dimensions;
89 size_t len;
90 const char *text;
97static void render_text(terminal_size_t size, const void *data);
98
108
109static screen_t g_screens[UI_SCREEN_COUNT];
110static ui_session_t *g_session;
111static _Thread_local frame_buffer_t *g_capture;
112static _Thread_local const animation_sample_t *g_animation;
113static lifecycle_t g_lifecycle = LIFECYCLE_INIT;
114static mutex_t g_mutex;
115static mutex_t g_title_packet_mutex;
116static cond_t g_render_done;
117static cond_t g_render_requested;
118static int g_rendering_screen = -1;
119static uint32_t g_rendering_layers;
120static uint64_t g_render_started;
121static uint64_t g_render_completed;
122static platform_thread_t g_thread;
123static atomic_t g_presentation_stop_requested = {0};
124static _Atomic bool g_sync_finishing;
125static _Atomic bool g_sync_rendering;
126static _Thread_local bool g_owner;
127static _Thread_local int g_render_fd = -1;
128static _Thread_local screen_snapshot_t *g_render_snapshot;
129static _Thread_local terminal_size_t g_render_size;
130// Input readers need geometry and minimums, never a borrowed screen snapshot.
131static _Atomic uint64_t g_size = UINT64_C(80) | (UINT64_C(24) << 32);
132// Lower bits identify published slots; upper bits identify covered slots.
133static _Atomic uint64_t g_input_state;
134_Static_assert(UI_SCREEN_COUNT <= 32, "Input publication reserves 32 bits per screen mask");
135
136static uint64_t pack_size(terminal_size_t size) {
137 return (uint32_t)size.cols | ((uint64_t)(uint32_t)size.rows << 32);
138}
139
140static terminal_size_t unpack_size(uint64_t packed) {
141 return (terminal_size_t){.cols = (int)(uint32_t)packed, .rows = (int)(uint32_t)(packed >> 32)};
142}
143
144static void publish_input_state_locked(void) {
145 terminal_size_t size = unpack_size(atomic_load(&g_size));
146 uint64_t state = 0;
147 for (int i = 0; i < UI_SCREEN_COUNT; ++i) {
148 if (!g_screens[i].render)
149 continue;
150 terminal_size_t minimum = g_screens[i].minimum;
151 if (i == UI_SCREEN_PROMPT)
152 minimum.rows = ui_prompt_required_rows(g_screens[i].snapshot, size.cols);
153 state |= UINT64_C(1) << i;
154 if (size.cols <= 0 || size.rows <= 0 || ui_too_small(size, minimum))
155 state |= UINT64_C(1) << (i + 32);
156 }
157 if (atomic_load(&g_input_state) != state)
158 atomic_store(&g_input_state, state);
159}
160
161static int g_active = -1;
162static _Atomic int g_interactive_screen = -1;
163static _Atomic int g_background_screen = -1;
164static _Atomic uint32_t g_screen_mask;
165static atomic_t g_volume_until = {0};
166/* The UI controller owns the terminal while any full-screen presentation is live. */
167static bool g_terminal_screen_active;
168static int g_terminal_screen_fd = -1;
169static _Thread_local bool g_render_volume;
170static _Thread_local bool g_volume_caps_valid;
171static _Thread_local terminal_capabilities_t g_volume_caps;
172static _Thread_local terminal_color_mode_t g_volume_color_mode;
173static _Thread_local color_setting_t g_volume_color_setting;
174static _Thread_local utf8_setting_t g_volume_utf8_setting;
175static _Thread_local terminal_render_mode_t g_volume_render_mode;
176
177static terminal_capabilities_t volume_capabilities(void) {
178 terminal_color_mode_t mode = GET_OPTION(color_mode);
179 color_setting_t color = GET_OPTION(color);
180 utf8_setting_t utf8 = GET_OPTION(force_utf8);
181 terminal_render_mode_t render = GET_OPTION(render_mode);
182 if (!g_volume_caps_valid || mode != g_volume_color_mode || color != g_volume_color_setting ||
183 utf8 != g_volume_utf8_setting || render != g_volume_render_mode) {
184 g_volume_caps = apply_color_mode_override(detect_terminal_capabilities(options_terminal_settings()),
186 g_volume_color_mode = mode;
187 g_volume_color_setting = color;
188 g_volume_utf8_setting = utf8;
189 g_volume_render_mode = render;
190 g_volume_caps_valid = true;
191 }
192 return g_volume_caps;
193}
194
195static void terminal_screen_enter(int fd) {
196 if (!platform_isatty(fd) || GET_OPTION(snapshot_mode))
197 return;
198 const char sequence[] = "\033[?1049h\033[?25l\033[H\033[2J";
199 size_t written = platform_write_all(fd, sequence, sizeof(sequence) - 1);
200 if (written > 0) {
201 g_terminal_screen_active = true;
202 g_terminal_screen_fd = fd;
203 }
204 if (written != sizeof(sequence) - 1)
205 log_error("Cannot completely enter alternate terminal screen");
206}
207
208static void terminal_screen_leave(void) {
209 if (!g_terminal_screen_active || g_terminal_screen_fd < 0)
210 return;
211 const char sequence[] = "\033[0m\033[?25h\033[?1049l";
212 size_t written = platform_write_all(g_terminal_screen_fd, sequence, sizeof(sequence) - 1);
213 if (written == sizeof(sequence) - 1) {
214 g_terminal_screen_active = false;
215 g_terminal_screen_fd = -1;
216 } else {
217 log_error("Cannot completely restore the normal terminal screen");
218 }
219}
220
222 if (!GET_OPTION(snapshot_mode))
223 atomic_store_u64(&g_volume_until, time_get_ns() + 1500 * NS_PER_MS_INT);
224}
225
226static void append_volume(frame_buffer_t *buffer, terminal_size_t size) {
227 if (size.cols < 8 || size.rows < 6)
228 return;
229 double volume = GET_OPTION(speakers_volume);
230 if (volume < 0.0)
231 volume = 0.0;
232 if (volume > 1.0)
233 volume = 1.0;
234 int height = size.rows - 4;
235 if (height > 10)
236 height = 10;
237 int filled = (int)(volume * height + 0.5);
238 int top = (size.rows - height - 1) / 2 + 1;
239 int col = size.cols - 5;
240 terminal_capabilities_t caps = volume_capabilities();
241 bool color = caps.color_level != TERM_COLOR_NONE && GET_OPTION(color) != COLOR_SETTING_FALSE;
242 bool utf8 = terminal_should_use_utf8(GET_OPTION(force_utf8));
243 frame_buffer_append(buffer, "\0337\033[0m", 6);
244 for (int row = 0; row < height; ++row) {
245 int level = height - row;
246 bool lit = level <= filled;
247 log_color_t shade = level * 3 <= height ? LOG_COLOR_ERROR
248 : level * 3 <= height * 2 ? LOG_COLOR_WARN
250 frame_buffer_printf(buffer, "\033[%d;%dH", top + row, col);
251 if (color)
252 frame_buffer_printf(buffer, "%s", ui_text_color_prefix(lit ? shade : LOG_COLOR_GREY));
253 const char *cells = utf8 ? (lit ? "██" : "░░") : (lit ? "##" : "..");
254 frame_buffer_printf(buffer, " %s \033[0m", cells);
255 }
256 frame_buffer_printf(buffer, "\033[%d;%dH", top + height, col);
257 if (volume <= 0.01)
258 frame_buffer_append(buffer, "MUTE", 4);
259 else
260 frame_buffer_printf(buffer, "%3d%%", (int)(volume * 100.0 + 0.5));
261 frame_buffer_append(buffer, "\033[0m\0338", 6);
262}
263
265 int background = atomic_load(&g_background_screen);
266 int interactive = atomic_load(&g_interactive_screen);
267 if (interactive == UI_SCREEN_HELP && (atomic_load(&g_screen_mask) & (1u << UI_SCREEN_HELP)) &&
270 return UI_SCREEN_HELP;
271 if ((background >= 0 || interactive >= 0) && ui_error_popup_visible())
272 return UI_SCREEN_ERROR;
273 if (interactive < 0 && background < UI_SCREEN_MDNS && (atomic_load(&g_screen_mask) & (1u << UI_SCREEN_MDNS)))
274 return UI_SCREEN_MDNS;
275 return background > interactive ? background : interactive;
276}
277
278static atomic_int g_default_fd = STDOUT_FILENO;
279
281 int fd = atomic_load(&g_default_fd);
282 if (!lifecycle_is_initialized(&g_lifecycle))
283 return fd;
284 mutex_lock(&g_mutex);
285 if (g_active >= 0 && g_active < UI_SCREEN_COUNT && g_screens[g_active].render)
286 fd = g_screens[g_active].fd;
287 mutex_unlock(&g_mutex);
288 return fd;
289}
290
291static bool interactive_screen(int screen) {
295}
296
298 return atomic_load(&g_interactive_screen);
299}
300
302 atomic_store(&g_interactive_screen, screen);
303}
304
305static int selected_screen_excluding_locked(int excluded) {
306 uint32_t visible = 0;
307 int interactive = ui_controller_interactive_screen();
308 if (interactive == UI_SCREEN_HELP)
310 for (int i = 0; i < UI_SCREEN_COUNT; ++i)
311 if (i != excluded && g_screens[i].render &&
312 (!interactive_screen(i) || i == interactive || (interactive < 0 && i == UI_SCREEN_MDNS)))
313 visible |= 1u << i;
315 g_screens[UI_SCREEN_HELP].render) {
316 // F12 covers existing help; help opened from the update checker covers it in turn.
317 if (excluded != UI_SCREEN_UPDATE && g_screens[UI_SCREEN_UPDATE].render &&
319 return UI_SCREEN_UPDATE;
320 return UI_SCREEN_HELP;
321 }
322 int selected = ui_session_select_screen(visible);
323 // Sync presents from its independent mailbox. Never fall through to media
324 // if it was selected after the presentation loop checked that mailbox.
325 return interactive == UI_SCREEN_SYNC && selected < UI_SCREEN_SYNC ? -1 : selected;
326}
327
328static int selected_screen_locked(void) {
329 return selected_screen_excluding_locked(-1);
330}
331
332static void publish_screen_locked(void) {
333 publish_input_state_locked();
334 uint32_t background = 0, live = 0;
335 for (int i = 0; i < UI_SCREEN_COUNT; ++i) {
336 if (g_screens[i].render) {
337 live |= 1u << i;
338 if (!interactive_screen(i))
339 background |= 1u << i;
340 }
341 }
342 atomic_store(&g_screen_mask, live);
343 int selected = ui_session_select_screen(background);
344 if (atomic_load(&g_background_screen) != selected)
345 atomic_store(&g_background_screen, selected);
346 cond_signal(&g_render_requested);
347}
348static bool g_redraw;
349static bool g_finished;
350static uint64_t g_media_frame_id;
351static _Thread_local uint64_t g_acquisition_started_ns;
352static _Thread_local bool g_probe_frame;
353static _Thread_local uint64_t g_probe_requested_ns;
354static _Thread_local fps_counter_t *g_fps;
355static atomic_t g_effective_fps = {.impl = 60};
356
358 g_acquisition_started_ns = started_ns;
359}
360
361void ui_controller_set_effective_fps(unsigned int fps) {
362 atomic_store_int(&g_effective_fps, (int)(fps > 0 ? fps : 60));
363 if (g_fps)
365}
366static atomic_t g_blocked = {0};
367static atomic_t g_live = {0};
368static _Thread_local bool g_help_sync_open;
369static _Thread_local bool g_stats_media, g_stats_failed;
370static _Thread_local uint64_t g_stats_write_ns, g_stats_writes, g_stats_max_chunk_ns, g_stats_chunks;
371static void stats_media_begin(bool media) {
372 g_stats_media = media;
373 g_stats_failed = false;
374 g_stats_write_ns = g_stats_writes = g_stats_max_chunk_ns = g_stats_chunks = 0;
375 g_probe_frame = false;
376 g_probe_requested_ns = 0;
377}
378static void stats_media_end(void) {
379 if (g_stats_media && g_stats_writes) {
383 }
384 g_stats_media = false;
385}
386
387static size_t write_output_chunk(int fd, const void *data, size_t length) {
388 uint64_t started_ns = g_stats_media ? time_get_ns() : 0;
389#ifndef NDEBUG
390 static _Thread_local bool test_delay_checked, test_delay;
391 if (!test_delay_checked) {
392 const char *value = SAFE_GETENV("ASCII_CHAT_TEST_SLOW_MEDIA_OUTPUT");
393 test_delay = value && strcmp(value, "1") == 0;
394 test_delay_checked = true;
395 }
396 if (g_stats_media && test_delay && length > 1024)
398#endif
399 // A presentation write must not split an image protocol packet emitted by
400 // another thread. Non-owner logging must remain nonblocking here.
401 if (g_owner)
403 size_t written = platform_write_all(fd, data, length);
404 if (g_owner)
406 if (g_stats_media) {
407 uint64_t elapsed_ns = time_get_ns() - started_ns;
408 if (elapsed_ns > g_stats_max_chunk_ns)
409 g_stats_max_chunk_ns = elapsed_ns;
410 ++g_stats_chunks;
411 }
412 return written;
413}
414
415// Media frames contain complete newline-separated rows; clipped frames end
416// each row with EL. Splitting after a row keeps CSI and UTF-8 sequences intact.
417static size_t media_chunk_boundary(const char *data, size_t length) {
418 if (!data || length == 0)
419 return 0;
420 const char *end = data + length;
421 const char *last = NULL;
422 for (const char *p = data; p < end;) {
423 const char *newline = memchr(p, '\n', (size_t)(end - p));
424 if (!newline)
425 break;
426 last = newline + 1;
427 p = last;
428 }
429 for (const char *p = data; p < end;) {
430 const char *k = memchr(p, 'K', (size_t)(end - p));
431 if (!k)
432 break;
433 if (k - data >= 2 && k[-2] == '\033' && k[-1] == '[' && (!last || k + 1 > last))
434 last = k + 1;
435 p = k + 1;
436 }
437 return last ? (size_t)(last - data) : 0;
438}
439
440size_t ui_controller_trim_utf8_chunk(const char *data, size_t length) {
441 while (data && length && ((unsigned char)data[length - 1] & 0xc0) == 0x80)
442 --length;
443 return length;
444}
445
446asciichat_error_t ui_controller_write(int fd, const char *data, size_t len) {
447 if (!data || !len)
448 return ASCIICHAT_OK;
449 if (g_capture) {
450 frame_buffer_append(g_capture, data, len);
451 return ASCIICHAT_OK;
452 }
453 // Logging may call this while holding subsystem locks; never wait for a
454 // renderer callback here. Live screens already capture logs in their model.
455 if (!g_owner && ui_controller_is_presenting())
456 return ASCIICHAT_OK;
457 if (g_owner && g_render_fd >= 0)
458 fd = g_render_fd;
460 uint64_t stats_start = g_stats_media ? time_get_ns() : 0;
461 // Terminals supporting synchronized output present the art and its overlays
462 // together, even when a large UTF-8 frame requires several writes.
463 bool help_media_overlay =
465 bool synchronized = g_render_snapshot != NULL || help_media_overlay;
466 g_help_sync_open = help_media_overlay;
467 bool started = !synchronized || write_output_chunk(fd, "\033[?2026h", 8) == 8;
468 uint64_t sync_started_ns = g_stats_media && synchronized ? time_get_ns() : 0;
469 size_t written = 0;
470 bool superseded = false;
471 while (started && written < len) {
472 // A pending editor or overlay supersedes the remainder of a media frame.
473 // Bound each console write so a large frame cannot delay that transition.
474 if (g_stats_media && ui_controller_current_screen() != UI_SCREEN_MEDIA) {
475 superseded = true;
476 break;
477 }
478 size_t count = len - written;
479 bool row_boundary = false;
480 if (g_stats_media && count > 32768) {
481 count = 32768;
482 size_t boundary = media_chunk_boundary(data + written, count);
483 if (boundary) {
484 count = boundary;
485 row_boundary = true;
486 }
487 // Never inspect beyond the candidate chunk: the next byte may belong
488 // to unrelated memory or to the next output chunk.
489 count = ui_controller_trim_utf8_chunk(data + written, count);
490 }
491 size_t sent = write_output_chunk(fd, data + written, count);
492 written += sent;
493 if (sent != count)
494 break;
495 if (synchronized && g_stats_media && row_boundary && written < len &&
496 time_get_ns() - sync_started_ns >= 40 * NS_PER_MS_INT) {
497 // Give the terminal a complete set of rows before its synchronized
498 // output watchdog can expose a partly written row.
499 if (write_output_chunk(fd, "\033[?2026l\033[?2026h", 16) != 16) {
500 started = false;
501 break;
502 }
503 sync_started_ns = time_get_ns();
504 }
505 }
506 // End the update even when input superseded the frame halfway through.
507 static const char end_with_query[] = "\033[?2026l\033]11;?\033\\";
508 bool probe = synchronized && g_probe_frame && written == len && started;
509 if (probe)
510 g_probe_requested_ns = time_get_ns();
511 size_t end_length = probe ? sizeof(end_with_query) - 1 : 8;
512 bool ended = g_help_sync_open || !synchronized || write_output_chunk(fd, end_with_query, end_length) == end_length;
513 if (!ended)
514 g_probe_requested_ns = 0;
515 // Switching screens intentionally cancels the remainder of an old media frame.
516 bool complete = (written == len || superseded) && started && ended;
517 if (g_stats_media) {
518 g_stats_write_ns += time_get_ns() - stats_start;
519 g_stats_writes++;
520 g_stats_failed |= !complete;
521 }
522 fps_counter_write_end(g_fps, complete);
523 return complete ? ASCIICHAT_OK
524 : SET_ERRNO(ERROR_FILE_OPERATION, "Incomplete terminal frame write (%zu/%zu bytes)", written, len);
525}
526
527static void release_terminal(screen_terminal_t *terminal) {
528 if (terminal && atomic_fetch_sub(&terminal->references, 1) == 1) {
529 platform_close(terminal->fd);
530 SAFE_FREE(terminal);
531 }
532}
533
534static void release_snapshot(screen_snapshot_t *snapshot) {
535 if (snapshot && atomic_fetch_sub(&snapshot->references, 1) == 1) {
536 release_terminal(snapshot->terminal);
537 snapshot->terminal = NULL;
538 if (snapshot->slot)
539 atomic_store_explicit(&snapshot->slot->busy, false, memory_order_release);
540 else
542 }
543}
544
545static screen_snapshot_t *allocate_snapshot(ui_screen_t screen, size_t bytes) {
546 media_snapshot_slot_t *slot = NULL;
547 if (screen == UI_SCREEN_MEDIA) {
548 for (size_t i = 0; i < 3; ++i) {
549 bool expected = false;
550 if (atomic_compare_exchange_strong_explicit(&g_media_snapshots[i].busy, &expected, true, memory_order_acquire,
551 memory_order_relaxed)) {
552 slot = &g_media_snapshots[i];
553 break;
554 }
555 }
556 }
557 screen_snapshot_t *copy;
558 if (slot) {
559 if (slot->capacity < bytes) {
560 size_t capacity = slot->capacity ? slot->capacity : 4096;
561 while (capacity < bytes && capacity <= SIZE_MAX / 2)
562 capacity *= 2;
563 if (capacity < bytes)
564 capacity = bytes;
565 slot->snapshot = SAFE_REALLOC(slot->snapshot, capacity, screen_snapshot_t *);
566 slot->capacity = capacity;
567 }
568 copy = slot->snapshot;
569 } else {
570 copy = SAFE_MALLOC(bytes, screen_snapshot_t *);
571 }
572 if (!copy) {
573 if (slot)
574 atomic_store(&slot->busy, false);
575 return NULL;
576 }
577 atomic_init(&copy->references, 1);
578 copy->slot = slot;
579 copy->terminal = NULL;
580 return copy;
581}
582
583static void destroy_media_snapshots(void) {
584 for (size_t i = 0; i < 3; ++i) {
585 bool expected = false;
586 if (atomic_compare_exchange_strong(&g_media_snapshots[i].busy, &expected, true)) {
587 SAFE_FREE(g_media_snapshots[i].snapshot);
588 g_media_snapshots[i].capacity = 0;
589 atomic_store(&g_media_snapshots[i].busy, false);
590 }
591 }
592}
593
594static void release_screen(screen_t *screen, bool flush_notices) {
595 screen_t *current = screen;
596 while (current) {
597 screen_t *next = current->next;
598 if (current->render) {
599 if (flush_notices)
600 ui_notice_flush_snapshot(current->fd, current->snapshot);
601 }
602 release_snapshot(current->owned);
603 if (current != screen)
604 SAFE_FREE(current);
605 current = next;
606 }
607 memset(screen, 0, sizeof(*screen));
608}
609
610void ui_controller_finish(int fd, const char *data, size_t len) {
611 atomic_store(&g_sync_finishing, true);
612 if (!g_owner)
613 while (atomic_load(&g_sync_rendering))
616 screen_t retired[UI_SCREEN_COUNT] = {0};
617 bool locked = lifecycle_is_initialized(&g_lifecycle);
618 if (locked) {
619 mutex_lock(&g_mutex);
620 g_finished = true;
621 memcpy(retired, g_screens, sizeof(retired));
622 memset(g_screens, 0, sizeof(g_screens));
623 atomic_store(&g_background_screen, -1);
624 atomic_store(&g_screen_mask, 0);
626 cond_signal(&g_render_requested);
627 uint64_t pending = g_render_started;
628 while (!g_render_snapshot && g_render_completed < pending)
629 cond_wait(&g_render_done, &g_mutex);
630 mutex_unlock(&g_mutex);
631 }
632 for (int i = 0; i < UI_SCREEN_COUNT; ++i)
633 release_screen(&retired[i], i == UI_SCREEN_NOTICE);
634 terminal_screen_leave();
635 platform_write_all(fd, data, len);
636 atomic_store_bool(&g_live, false);
637}
638
640 return atomic_load_bool(&g_blocked);
641}
642
646
648 return g_owner;
649}
650
651terminal_size_t ui_controller_size(void) {
652 if (g_owner)
653 return g_render_size;
654 terminal_size_t size = {.cols = 80, .rows = 24};
655 if (lifecycle_is_initialized(&g_lifecycle)) {
656 size = unpack_size(atomic_load(&g_size));
657 }
658 // A title-only controller has no screen to poll geometry yet.
659 if (!lifecycle_is_initialized(&g_lifecycle) || atomic_load(&g_screen_mask) == 0) {
660 terminal_size_t detected = {0};
662 int fd = atomic_load(&g_default_fd);
663 if (platform_isatty(fd) && terminal_get_size_fd(fd, &detected) == ASCIICHAT_OK && detected.cols > 0 &&
664 detected.rows > 0)
665 size = detected;
667 }
668 return size;
669}
670
672 return g_animation;
673}
674
693
694static void draw_native_view(frame_buffer_t *output, terminal_size_t size, const animation_sample_t *animation,
695 void *context) {
696 native_view_t *view = context;
697 g_capture = output;
698 g_animation = animation;
699 g_render_volume = view->volume;
700 bool previous = errno_notifications_suppress(true);
701 if (view->errors || view->layer_count > 1)
703 if (view->sync_backdrop)
704 ui_sync_render(size);
705 for (size_t i = 0; i < view->layer_count; ++i) {
706 screen_t *layer = &view->layers[i];
707 bool help = i + 1 == view->layer_count && view->active == UI_SCREEN_HELP;
708 if (help && view->errors)
709 ui_error_popup_render(output, size);
710 if (ui_too_small(size, layer->minimum)) {
711 ui_too_small_render(output, size, layer->minimum);
712 continue;
713 }
714 screen_snapshot_t *previous_snapshot = g_render_snapshot;
715 g_render_snapshot = layer->owned;
716 layer->render(size, layer->snapshot);
717 g_render_snapshot = previous_snapshot;
718 }
719 uint64_t media_frame_id = 0;
720 if (!view->errors) {
721 for (size_t i = 0; i < view->layer_count; ++i) {
722 if (view->layers[i].render == render_text && view->layers[i].owned) {
723 media_frame_id = view->layers[i].owned->frame_id;
724 break;
725 }
726 }
727 }
728 // Measure the media layer wherever it is rendered, including beneath an
729 // interactive TUI overlay. The active screen is not sufficient here:
730 // keyboard help, stats, and other screens can still compose and print the
731 // current ASCII frame.
732 view->fresh = fps_counter_frame_begin_id(g_fps, media_frame_id);
733 if (view->errors && view->active != UI_SCREEN_HELP)
734 ui_error_popup_render(output, size);
735 if (view->errors && terminal_screen_logs_help_open() &&
736 (view->active == UI_SCREEN_LOGS || view->active == UI_SCREEN_STATUS || view->active == UI_SCREEN_SPLASH))
739 g_render_volume = false;
740 g_animation = NULL;
741 g_capture = NULL;
742 view->drew = true;
743}
744
745static atomic_t g_title_active = {0};
746static bool g_title_saved;
747static bool g_title_written;
748static bool g_title_startup_finished;
749static int g_title_fd = -1;
750static uint64_t g_title_started;
751static atomic_t g_title_render = {0};
752static atomic_t g_title_ready = {0};
753static char g_last_title[TUI_CONTROLLER_LAST_TITLE_BUFFER_SIZE];
754static const char *g_title_mode;
755static atomic_t g_title_activity = {0};
756
758 atomic_store_bool(&g_title_ready, true);
759}
761 atomic_store_int(&g_title_render, phase);
762 if (phase != UI_TITLE_IDLE)
764}
765
767 atomic_store_int(&g_title_activity, activity);
768}
769void ui_controller_title_discovery(bool has_session, bool joining) {
771 : has_session ? UI_TITLE_WAITING_PEERS
773}
774
775static const char *title_screen_label(int screen, const void *snapshot) {
776 switch (screen) {
777 case UI_SCREEN_LOGS:
778 return "log viewer";
779 case UI_SCREEN_ERRNO:
780 return "errors";
781 case UI_SCREEN_STATS:
782 return "statistics";
783 case UI_SCREEN_SYNC:
784 return "sync primitives";
785 case UI_SCREEN_MDNS:
786 return "LAN server discovery";
787 case UI_SCREEN_UPDATE:
788 return "updates";
789 case UI_SCREEN_PROMPT:
790 return snapshot ? ui_prompt_title(snapshot) : "input";
791 case UI_SCREEN_NOTICE:
792 return snapshot ? ui_notice_title(snapshot) : "notice";
793 default:
794 return NULL;
795 }
796}
797
798static void title_update(int screen, const void *snapshot, bool covered) {
799 if (!atomic_load_bool(&g_title_active))
800 return;
801 // Preserve the shell's title once for application exit, never for view navigation.
802 if (!g_title_saved) {
804 mutex_lock(&g_title_packet_mutex);
805 (void)terminal_title_save(g_title_fd);
806 mutex_unlock(&g_title_packet_mutex);
808 g_title_saved = true;
809 }
810 char title[TUI_CONTROLLER_TITLE_BUFFER_SIZE] = {0};
811 asciichat_error_context_t latest = {0};
812 size_t errors = errno_title_snapshot(&latest);
813 if (errors) {
814 char count[32] = "";
815 if (errors > 1)
816 snprintf(count, sizeof(count), "(%zu) ", errors);
817 snprintf(title, sizeof(title), "‼️ ascii-chat %s %sERROR code=%d - %s",
818 terminal_should_use_utf8(GET_OPTION(force_utf8)) ? "•" : "*", count, (int)latest.code,
819 asciichat_error_string(latest.code));
820 } else if (terminal_should_use_utf8(GET_OPTION(force_utf8))) {
821 int phase = atomic_load_int(&g_title_render);
822 int activity = atomic_load_int(&g_title_activity);
823 if (phase != UI_TITLE_IDLE || activity != UI_TITLE_ACTIVITY_IDLE || screen >= UI_SCREEN_LOGS ||
824 (atomic_load_bool(&g_title_ready) && time_get_ns() - g_title_started >= 3 * NS_PER_SEC_INT))
825 g_title_startup_finished = true;
826 bool startup = !g_title_startup_finished;
827 const char *status = phase == UI_TITLE_RENDERING ? "rendering file..."
828 : phase == UI_TITLE_FINALIZING ? "finalizing file..."
829 : phase == UI_TITLE_FAILED ? "rendering failed"
830 : activity == UI_TITLE_CONNECTING ? "connecting..."
831 : activity == UI_TITLE_RECONNECTING ? "reconnecting..."
832 : activity == UI_TITLE_CREATING_SESSION ? "creating session..."
833 : activity == UI_TITLE_JOINING_SESSION ? "joining session..."
834 : activity == UI_TITLE_WAITING_PEERS ? "waiting for peers..."
835 : NULL;
836 bool spinning = phase == UI_TITLE_RENDERING || phase == UI_TITLE_FINALIZING ||
837 (phase == UI_TITLE_IDLE && activity != UI_TITLE_ACTIVITY_IDLE);
838 // Spinner glyphs use one column; reserve the emoji's second column with padding.
839 const char *icon = spinning ? ui_spinner_glyph(time_get_ns() - g_title_started, false) : "🔠";
840 snprintf(title, sizeof(title), "%s%s ascii-chat • %s%s%s", icon, spinning ? " " : "",
841 startup ? "video chat in your terminal" : g_title_mode, status ? " • " : "", status ? status : "");
843 const char *label;
844 if (screen == UI_SCREEN_HELP) {
845 mutex_lock(&g_mutex);
846 label = title_screen_label(parent, parent >= 0 && parent < UI_SCREEN_COUNT ? g_screens[parent].snapshot : NULL);
847 mutex_unlock(&g_mutex);
848 } else {
849 label = title_screen_label(parent, snapshot);
850 }
851 if (parent == UI_SCREEN_RENDER_PROGRESS && phase == UI_TITLE_IDLE)
852 label = "load file/url";
853 size_t used = strlen(title);
854 if (label)
855 snprintf(title + used, sizeof(title) - used, " • %s", label);
856 used = strlen(title);
858 snprintf(title + used, sizeof(title) - used, " • help");
859 used = strlen(title);
860 if (covered)
861 snprintf(title + used, sizeof(title) - used, " • terminal too small");
862 }
863 if (!g_title_written || strcmp(title, g_last_title) != 0) {
865 mutex_lock(&g_title_packet_mutex);
866 (void)terminal_set_title_fd(g_title_fd, title);
867 mutex_unlock(&g_title_packet_mutex);
868 // Retry cosmetic failures only when the desired text changes.
869 snprintf(g_last_title, sizeof(g_last_title), "%s", title);
870 g_title_written = true;
872 }
873}
874
875static const char *usage_screen_name(int screen) {
876 static const char *names[UI_SCREEN_COUNT] = {
877 [UI_SCREEN_STATUS] = "status", [UI_SCREEN_MEDIA] = "media",
878 [UI_SCREEN_SPLASH] = "splash", [UI_SCREEN_RENDER_PROGRESS] = "render_progress",
879 [UI_SCREEN_LOGS] = "logs", [UI_SCREEN_ERRNO] = "errno",
880 [UI_SCREEN_HELP] = "help", [UI_SCREEN_STATS] = "stats",
881 [UI_SCREEN_SYNC] = "sync", [UI_SCREEN_MDNS] = "mdns",
882 [UI_SCREEN_UPDATE] = "update", [UI_SCREEN_NOTICE] = "notice",
883 [UI_SCREEN_PROMPT] = "prompt", [UI_SCREEN_ERROR] = "error"};
884 if (screen == UI_SCREEN_PROMPT) {
885 mutex_lock(&g_mutex);
886 const char *name = ui_prompt_usage_name(g_screens[UI_SCREEN_PROMPT].snapshot);
887 mutex_unlock(&g_mutex);
888 return name;
889 }
890 if (screen == UI_SCREEN_HELP)
893 return "logs_help";
894 return screen >= 0 && screen < UI_SCREEN_COUNT ? names[screen] : NULL;
895}
896
897// Only the presentation thread owns this state. Repaints do not create visits.
898static void usage_transition(const char *next, const char **previous, uint64_t *since) {
899 if (next == *previous || (next && *previous && strcmp(next, *previous) == 0))
900 return;
901 uint64_t now = time_get_ns();
902 if (*previous)
903 ascii_otel_ui_usage(*previous, "leave", now - *since);
904 if (next)
905 ascii_otel_ui_usage(next, "enter", 0);
906 *previous = next;
907 *since = now;
908}
909
910static void *presentation_main(void *unused) {
911 (void)unused;
912 g_owner = true;
913 g_fps = fps_counter_create();
914 unsigned int applied_effective_fps = (unsigned int)atomic_load_int(&g_effective_fps);
915 fps_counter_set_effective_fps(g_fps, applied_effective_fps);
916 bool was_sync = false;
917 uint64_t last_volume_until = 0;
918 bool volume_was_visible = false;
919 uint64_t help_revision = 0;
920 uint64_t reply_requested_ns = 0, reply_frame_ns = 0, reply_frame_id = 0, reply_written_ns = 0;
921 int reply_support = 0;
922 bool awaiting_reply = false;
923 uint64_t error_revision = 0;
924 const char *usage_screen = NULL;
925 uint64_t usage_since = 0;
926 const char *stats_page = NULL, *covered_screen = NULL;
927 uint64_t stats_since = 0, covered_since = 0;
928 while (!atomic_load_bool(&g_presentation_stop_requested)) {
929 unsigned int effective_fps = (unsigned int)atomic_load_int(&g_effective_fps);
930 if (effective_fps != applied_effective_fps) {
931 fps_counter_set_effective_fps(g_fps, effective_fps);
932 applied_effective_fps = effective_fps;
933 }
934 int usage_current = ui_controller_current_screen();
935 usage_transition(usage_screen_name(usage_current), &usage_screen, &usage_since);
937 const char *next_page = NULL;
938 if (usage_current == UI_SCREEN_STATS && !usage_state.covered) {
939 mutex_lock(&g_mutex);
940 const stats_view_t *view = g_screens[UI_SCREEN_STATS].snapshot;
941 if (view)
942 next_page = view->page ? "stats_details" : "stats_overview";
943 mutex_unlock(&g_mutex);
944 }
945 usage_transition(next_page, &stats_page, &stats_since);
946 const char *next_covered = usage_state.covered ? usage_screen_name(usage_state.screen) : NULL;
947 if (next_covered != covered_screen && (!next_covered || !covered_screen || strcmp(next_covered, covered_screen))) {
948 uint64_t now = time_get_ns();
949 if (covered_screen) {
950 ascii_otel_ui_usage(covered_screen, "size_warning_ended", now - covered_since);
951 if (!next_covered && usage_screen && !strcmp(covered_screen, usage_screen))
952 ascii_otel_ui_usage(covered_screen, "size_warning_resolved", 0);
953 }
954 if (next_covered)
955 ascii_otel_ui_usage(next_covered, "size_warning_started", 0);
956 covered_screen = next_covered;
957 covered_since = now;
958 }
959 uint64_t iteration_started_ns = time_get_ns();
961 title_update(-1, NULL, false);
962 uint64_t refresh_deadline = iteration_started_ns + 16 * NS_PER_MS_INT;
964 if (reply_requested_ns && replied_ns >= reply_requested_ns) {
965 reply_support = 1;
966 if (awaiting_reply) {
967 fps_counter_frame_end(g_fps, replied_ns);
968 log_debug_every(US_PER_SEC_INT, "TERMINAL_ACK: id=%llu response_ms=%.2f capture_to_terminal_ms=%.2f",
969 (unsigned long long)reply_frame_id, (double)(replied_ns - reply_requested_ns) / NS_PER_MS_INT,
970 reply_frame_ns ? (double)(replied_ns - reply_frame_ns) / NS_PER_MS_INT : 0);
971 }
972 awaiting_reply = false;
973 }
974 if (awaiting_reply && iteration_started_ns - reply_requested_ns > 500 * NS_PER_MS_INT) {
975 // Classic consoles need no remote acknowledgement and may not answer OSC.
976 reply_support = -1;
977 awaiting_reply = false;
978 fps_counter_frame_end(g_fps, reply_written_ns);
979 }
982 // Keep only the latest mailbox frame while the frontend parses output.
983 // Input and modal screens remain independent of this backpressure.
985 continue;
986 }
987 // This path must remain independent of producers, including one stuck
988 // holding the controller mutex. It reads only the collector's mailbox.
992 atomic_store(&g_sync_rendering, true);
993 if (atomic_load(&g_sync_finishing)) {
994 atomic_store(&g_sync_rendering, false);
996 continue;
997 }
998 if (!was_sync) {
999 const ui_session_view_t hidden = {.screen = -1};
1000 ui_session_render(g_session, &hidden, time_get_ns());
1001 }
1002 int fd = ui_sync_output_fd();
1003 terminal_size_t size = {0};
1004 g_render_fd = fd;
1005 if (terminal_get_size_fd(fd, &size) == ASCIICHAT_OK && size.cols > 0 && size.rows > 0) {
1006 g_render_size = size;
1007 title_update(UI_SCREEN_SYNC, NULL, false);
1008 atomic_store_bool(&g_blocked, false);
1009 if (ui_error_popup_visible()) {
1010 frame_buffer_t *composed = frame_buffer_create(size.rows, size.cols);
1011 if (composed) {
1012 bool previous = errno_notifications_suppress(true);
1013 g_capture = composed;
1014 ui_sync_render(size);
1015 ui_error_popup_render(composed, size);
1016 g_capture = NULL;
1019 frame_buffer_destroy(composed);
1020 }
1021 } else {
1022 ui_sync_render(size);
1023 }
1024 atomic_store(&g_size, pack_size(size));
1025 }
1026 g_render_fd = -1;
1027 atomic_store(&g_sync_rendering, false);
1028 was_sync = true;
1029 platform_sleep_ns(50 * NS_PER_MS_INT);
1030 continue;
1031 }
1032 if (was_sync) {
1033 g_active = -1;
1034 ui_session_redraw(g_session);
1035 was_sync = false;
1036 }
1037 mutex_lock(&g_mutex);
1038 int active = selected_screen_locked();
1039 bool changed = active != g_active;
1040 g_active = active;
1041 bool redraw = g_redraw;
1042 g_redraw = false;
1043 terminal_size_t previous_size = unpack_size(atomic_load(&g_size));
1044 if ((active == UI_SCREEN_MEDIA || active == UI_SCREEN_HELP || active == UI_SCREEN_STATS) &&
1045 time_get_ns() >= atomic_load_u64(&g_volume_until))
1046 refresh_deadline = iteration_started_ns + 100 * NS_PER_MS_INT;
1047 screen_t frame = {0};
1048 screen_t layers[TUI_LAYER_CAPACITY] = {0};
1049 size_t layer_count = 0;
1050 if (active >= 0 && !g_finished && (!shutdown_is_requested() || active == UI_SCREEN_RENDER_PROGRESS)) {
1051 frame = g_screens[active];
1052 atomic_fetch_add(&frame.owned->references, 1);
1053 g_screens[active].dirty = false;
1054 g_rendering_screen = active;
1055 ++g_render_started;
1056 }
1057 mutex_unlock(&g_mutex);
1058 if (active < 0) {
1059 if (changed) {
1060 const char restore[] = "\033[0m\033[2J\033[H\033[?25h";
1061 ui_controller_write(atomic_load(&g_default_fd), restore, sizeof(restore) - 1);
1062 }
1063 const ui_session_view_t hidden = {.screen = -1};
1064 ui_session_render(g_session, &hidden, time_get_ns());
1065 atomic_store_bool(&g_blocked, false);
1066 }
1067 if (frame.render) {
1068 g_render_snapshot = frame.owned;
1069 screen_t *screen = &frame;
1070 terminal_resize_watcher_t watcher = {.size = previous_size};
1071 bool resized = false;
1072 // Observe physical changes once for all modes; consumers use the published dimensions.
1073 if (terminal_resize_poll(&watcher, screen->fd, &resized) != ASCIICHAT_OK) {
1074 // A minimized or detached terminal has no usable drawing area.
1075 atomic_store_bool(&g_blocked, true);
1076 const ui_session_view_t hidden = {.screen = -1};
1077 ui_session_render(g_session, &hidden, time_get_ns());
1078 goto frame_complete;
1079 }
1080 uint64_t geometry_done_ns = time_get_ns();
1081 terminal_size_t size = watcher.size;
1082 if (size.cols != previous_size.cols || size.rows != previous_size.rows) {
1083 mutex_lock(&g_mutex);
1084 atomic_store(&g_size, pack_size(size));
1085 publish_input_state_locked();
1086 mutex_unlock(&g_mutex);
1087 }
1088 g_render_size = size;
1089 if (active == UI_SCREEN_PROMPT)
1091 title_update(active, screen->snapshot, ui_too_small(size, screen->minimum));
1092 uint64_t now = time_get_ns();
1093 uint64_t volume_until = atomic_load_u64(&g_volume_until);
1094 bool volume_visible =
1095 active == UI_SCREEN_MEDIA && !GET_OPTION(snapshot_mode) && !GET_OPTION(strip_ansi) && now < volume_until;
1096 bool volume_changed =
1097 volume_visible != volume_was_visible || (volume_visible && volume_until != last_volume_until);
1098 if (volume_was_visible && !volume_visible)
1099 redraw = true;
1100 volume_was_visible = volume_visible;
1101 last_volume_until = volume_until;
1102 int fps_screen = active == UI_SCREEN_HELP ? keyboard_help_parent_screen() : active;
1103 bool show_fps = fps_screen == UI_SCREEN_MEDIA && GET_OPTION(fps_counter);
1104 redraw |= fps_counter_set_visible(g_fps, show_fps);
1105 bool was_covered = ui_session_is_covered(g_session);
1106 // Opening keyboard help does not stop the media layer underneath it.
1107 // Preserve its measured FPS instead of resetting the overlay to zero
1108 // merely because the active presentation screen changed to HELP.
1109 if ((changed && fps_screen != UI_SCREEN_MEDIA) || was_covered)
1110 fps_counter_reset(g_fps);
1111 g_render_fd = screen->fd;
1113 bool errors = ui_error_popup_visible();
1114 uint64_t current_error_revision = ui_error_popup_revision();
1115 bool errors_changed = current_error_revision != error_revision;
1116 error_revision = current_error_revision;
1117 bool overlay = errors || active == UI_SCREEN_HELP || active == UI_SCREEN_UPDATE;
1118 bool sync_backdrop = false;
1119 mutex_lock(&g_mutex);
1120 if (active == UI_SCREEN_HELP || active == UI_SCREEN_UPDATE) {
1121 int parent = selected_screen_excluding_locked(active);
1122 // Help can cover the update dialog, which itself covers the retained TUI.
1123 if (parent == UI_SCREEN_UPDATE && active == UI_SCREEN_HELP) {
1124 int underlying = keyboard_help_previous_interactive();
1125 if (underlying >= 0 && underlying != UI_SCREEN_HELP && g_screens[underlying].render)
1126 layers[layer_count++] = g_screens[underlying];
1127 else {
1128 for (int i = UI_SCREEN_SPLASH; i >= UI_SCREEN_STATUS; --i)
1129 if (g_screens[i].render) {
1130 layers[layer_count++] = g_screens[i];
1131 break;
1132 }
1133 }
1134 }
1135 if (parent >= 0 && parent != active && g_screens[parent].render)
1136 layers[layer_count++] = g_screens[parent];
1137 sync_backdrop = keyboard_help_previous_interactive() == UI_SCREEN_SYNC && layer_count == 0;
1138 }
1139 // The active frame already has its own lifetime reference.
1140 for (size_t i = 0; i < layer_count; ++i) {
1141 atomic_fetch_add(&layers[i].owned->references, 1);
1142 }
1143 g_rendering_layers = 1u << active;
1144 for (int i = 0; i < UI_SCREEN_COUNT; ++i)
1145 for (size_t j = 0; j < layer_count; ++j)
1146 if (g_screens[i].owned == layers[j].owned)
1147 g_rendering_layers |= 1u << i;
1148 mutex_unlock(&g_mutex);
1149 layers[layer_count++] = frame;
1150 if ((resized || changed) && (active == UI_SCREEN_MEDIA || overlay)) {
1151 // Keep the last usable media dimensions while a small-terminal view is shown.
1152 if (GET_OPTION(auto_width) && size.cols >= UI_MEDIA_MIN_COLS && size.cols <= 1024)
1153 options_set_int("width", size.cols);
1154 if (GET_OPTION(auto_height) && size.rows >= UI_MEDIA_MIN_ROWS && size.rows <= 1024)
1155 options_set_int("height", size.rows);
1156 }
1157 ui_session_resize(g_session, size.cols, size.rows);
1158 if (redraw)
1159 ui_session_redraw(g_session);
1160 if (active == UI_SCREEN_MEDIA) {
1161 // Console geometry queries can block while capture continues. Take the
1162 // latest mailbox snapshot after those queries instead of drawing the
1163 // image retained before them.
1164 screen_snapshot_t *retired = NULL;
1165 mutex_lock(&g_mutex);
1166 if (g_screens[active].owned && g_screens[active].owned != frame.owned) {
1167 retired = frame.owned;
1168 frame = g_screens[active];
1169 atomic_fetch_add(&frame.owned->references, 1);
1170 g_screens[active].dirty = false;
1171 g_render_snapshot = frame.owned;
1172 }
1173 mutex_unlock(&g_mutex);
1174 release_snapshot(retired);
1175 }
1176 layers[layer_count - 1] = frame;
1177 native_view_t context = {.screen = screen,
1178 .layers = layers,
1179 .layer_count = layer_count,
1180 .active = active,
1181 .volume = volume_visible,
1182 .errors = errors,
1183 .sync_backdrop = sync_backdrop};
1184 bool help_changed = false;
1185 if (active == UI_SCREEN_HELP) {
1186 uint64_t revision = options_revision();
1187 help_changed = revision != help_revision;
1188 help_revision = revision;
1189 }
1190 ui_session_view_t view = {
1191 .screen = active,
1192 .minimum = errors && active != UI_SCREEN_HELP ? (terminal_size_t){1, 1} : screen->minimum,
1193 .dirty = screen->dirty || errors_changed || volume_changed || help_changed,
1194 .refresh_ns = active == UI_SCREEN_PROMPT ? 50 * NS_PER_MS_INT
1195 : active == UI_SCREEN_LOGS ? 100 * NS_PER_MS_INT
1196 : active == UI_SCREEN_UPDATE ? (update_banner_is_checking() ? 100 * NS_PER_MS_INT : 0)
1197 : active == UI_SCREEN_RENDER_PROGRESS
1198 ? 125 * NS_PER_MS_INT
1199 : active == UI_SCREEN_NOTICE || active == UI_SCREEN_ERROR ? 0
1200 : (active != UI_SCREEN_MEDIA && active != UI_SCREEN_STATS && active != UI_SCREEN_HELP
1201 ? 16 * NS_PER_MS_INT
1202 : 0),
1203 .draw = draw_native_view,
1204 .context = &context};
1205 uint64_t compose_started_ns = time_get_ns();
1206 const char *output = ui_session_render(g_session, &view, now);
1207 uint64_t animation_deadline = ui_session_next_frame_ns(g_session);
1208 if (active == UI_SCREEN_HELP && animation_deadline < refresh_deadline)
1209 refresh_deadline = animation_deadline;
1210 uint64_t composed_ns = time_get_ns();
1211 bool covered = ui_session_is_covered(g_session);
1212 atomic_store_bool(&g_blocked, covered);
1213 if (covered && !was_covered)
1214 fps_counter_reset(g_fps);
1215 if (output) {
1216 stats_media_begin(context.fresh && context.drew);
1217 // Probe terminal acknowledgement support during startup only. Once a
1218 // frontend has answered (or timed out), media presentation must not
1219 // make every frame wait on an optional terminal protocol round trip.
1220 // The mailbox already retains the newest frame when a frontend is
1221 // temporarily busy, so steady-state video should follow capture FPS.
1222 g_probe_frame = context.fresh && context.drew && reply_support == 0;
1224 uint64_t write_done_ns = time_get_ns();
1225 if (written == ASCIICHAT_OK && active == UI_SCREEN_NOTICE && context.drew && !covered && !errors)
1227 if (context.fresh && context.drew) {
1229 // A frontend reply drains the complete update. A local console query
1230 // adds an RPC without establishing any stronger completion boundary.
1231 asciichat_error_t drained = g_probe_requested_ns ? ASCIICHAT_OK : terminal_flush(screen->fd);
1232 fps_counter_write_end(g_fps, drained == ASCIICHAT_OK);
1233 if (written != ASCIICHAT_OK || drained != ASCIICHAT_OK)
1234 CLEAR_ERRNO();
1235 else {
1236 uint64_t completed_ns = time_get_ns();
1239 "FRAME_PRESENT: id=%llu submit_to_stdout_ms=%.2f acquired_ns=%llu submitted_ns=%llu "
1240 "completed_ns=%llu capture_to_stdout_ms=%.2f mailbox_ms=%.2f geometry_ms=%.2f "
1241 "compose_ms=%.2f write_ms=%.2f drain_ms=%.2f max_chunk_ms=%.2f chunks=%llu bytes=%zu cols=%d rows=%d",
1242 (unsigned long long)screen->owned->frame_id,
1243 (double)(completed_ns - screen->owned->submitted_ns) / NS_PER_MS_INT,
1244 (unsigned long long)screen->owned->acquisition_started_ns,
1245 (unsigned long long)screen->owned->submitted_ns, (unsigned long long)completed_ns,
1247 ? (double)(completed_ns - screen->owned->acquisition_started_ns) / NS_PER_MS_INT
1248 : 0,
1249 (double)(compose_started_ns - screen->owned->submitted_ns) / NS_PER_MS_INT,
1250 (double)(geometry_done_ns - iteration_started_ns) / NS_PER_MS_INT,
1251 (double)(composed_ns - compose_started_ns) / NS_PER_MS_INT,
1252 (double)(write_done_ns - composed_ns) / NS_PER_MS_INT,
1253 (double)(completed_ns - write_done_ns) / NS_PER_MS_INT, (double)g_stats_max_chunk_ns / NS_PER_MS_INT,
1254 (unsigned long long)g_stats_chunks, ui_session_frame_length(g_session), size.cols, size.rows);
1255 }
1256 }
1257 stats_media_end();
1258 if (g_probe_frame) {
1259 awaiting_reply = written == ASCIICHAT_OK && g_probe_requested_ns != 0;
1260 if (awaiting_reply) {
1261 reply_requested_ns = g_probe_requested_ns;
1262 reply_frame_ns = screen->owned->acquisition_started_ns;
1263 reply_frame_id = screen->owned->frame_id;
1264 reply_written_ns = time_get_ns();
1265 }
1266 }
1267 if (context.drew && !awaiting_reply)
1268 fps_counter_frame_end(g_fps, time_get_ns());
1269 }
1270 // The FPS meter is the final overlay: draw it once after the complete
1271 // ASCII/TUI composition has been written, without clearing anything.
1272 if (!covered && show_fps)
1273 fps_counter_render(g_fps, screen->fd, size.cols, active == UI_SCREEN_HELP);
1274 if (g_help_sync_open) {
1275 (void)write_output_chunk(screen->fd, "\033[?2026l", 8);
1276 g_help_sync_open = false;
1277 }
1278 }
1279 frame_complete:
1280 g_render_fd = -1;
1281 g_render_snapshot = NULL;
1282 for (size_t i = 0; i + 1 < layer_count; ++i)
1283 release_snapshot(layers[i].owned);
1284 if (frame.render) {
1285 release_snapshot(frame.owned);
1286 mutex_lock(&g_mutex);
1287 g_rendering_screen = -1;
1288 g_rendering_layers = 0;
1289 g_render_completed = g_render_started;
1290 bool live = false;
1291 for (int i = 0; i < UI_SCREEN_COUNT; ++i)
1292 live |= g_screens[i].render != NULL;
1293 atomic_store_bool(&g_live, live);
1294 if (!live) {
1296 ui_error_popup_preserve(atomic_load(&g_default_fd), unpack_size(atomic_load(&g_size)));
1298 }
1299 cond_broadcast(&g_render_done);
1300 mutex_unlock(&g_mutex);
1301 }
1302 // New snapshots wake presentation immediately. The deadline only refreshes
1303 // animations, terminal geometry and idle FPS counters; rendering time is
1304 // part of the interval rather than an additional delay after each frame.
1305 mutex_lock(&g_mutex);
1306 int pending_screen = selected_screen_locked();
1307 bool pending = g_redraw || pending_screen != g_active || (pending_screen >= 0 && g_screens[pending_screen].dirty) ||
1308 (ui_error_popup_visible() && g_screens[UI_SCREEN_MEDIA].dirty);
1309 uint64_t now = time_get_ns();
1310 if (!pending && !atomic_load_bool(&g_presentation_stop_requested) && now < refresh_deadline)
1311 cond_timedwait(&g_render_requested, &g_mutex, refresh_deadline - now);
1312 mutex_unlock(&g_mutex);
1313 }
1314 usage_transition(NULL, &stats_page, &stats_since);
1315 if (covered_screen)
1316 ascii_otel_ui_usage(covered_screen, "size_warning_ended", time_get_ns() - covered_since);
1317 usage_transition(NULL, &usage_screen, &usage_since);
1318 if (g_title_saved) {
1320 (void)terminal_title_restore(g_title_fd);
1321 (void)platform_close(g_title_fd);
1322 g_title_fd = -1;
1323 g_title_saved = false;
1324 atomic_store_bool(&g_title_active, false);
1326 }
1327 fps_counter_destroy(g_fps);
1328 g_fps = NULL;
1329 g_owner = false;
1330 return NULL;
1331}
1332
1333static asciichat_error_t controller_start(void) {
1335 if (lifecycle_init_once(&g_lifecycle)) {
1336 atomic_store_bool(&g_presentation_stop_requested, false);
1337 atomic_store(&g_sync_finishing, false);
1338 atomic_store(&g_sync_rendering, false);
1339 atomic_store_bool(&g_blocked, false);
1340 g_active = -1;
1341 g_redraw = false;
1342 g_finished = false;
1343 g_rendering_screen = -1;
1344 g_render_started = g_render_completed = 0;
1345 if (mutex_init(&g_mutex, "ui_controller") != 0) {
1346 lifecycle_init_abort(&g_lifecycle);
1347 return SET_ERRNO(ERROR_THREAD, "Cannot initialize UI mutex");
1348 }
1349 if (mutex_init(&g_title_packet_mutex, "terminal_title_packets") != 0) {
1350 mutex_destroy(&g_mutex);
1351 lifecycle_init_abort(&g_lifecycle);
1352 return SET_ERRNO(ERROR_THREAD, "Cannot initialize terminal title packet mutex");
1353 }
1354 if (cond_init(&g_render_done, "ui_render_done") != 0) {
1355 mutex_destroy(&g_title_packet_mutex);
1356 mutex_destroy(&g_mutex);
1357 lifecycle_init_abort(&g_lifecycle);
1358 return SET_ERRNO(ERROR_THREAD, "Cannot initialize UI render completion condition");
1359 }
1360 if (cond_init(&g_render_requested, "ui_render_requested") != 0) {
1361 cond_destroy(&g_render_done);
1362 mutex_destroy(&g_title_packet_mutex);
1363 mutex_destroy(&g_mutex);
1364 lifecycle_init_abort(&g_lifecycle);
1365 return SET_ERRNO(ERROR_THREAD, "Cannot initialize UI presentation condition");
1366 }
1367 g_session = ui_session_create();
1368 if (!g_session) {
1369 cond_destroy(&g_render_requested);
1370 cond_destroy(&g_render_done);
1371 mutex_destroy(&g_title_packet_mutex);
1372 mutex_destroy(&g_mutex);
1373 lifecycle_init_abort(&g_lifecycle);
1374 return SET_ERRNO(ERROR_MEMORY, "Cannot create session UI runtime");
1375 }
1376 NAMED_REGISTER_ATOMIC(&g_title_active, "terminal_title_active", NULL);
1377 NAMED_REGISTER_ATOMIC(&g_title_render, "terminal_title_render", NULL);
1378 NAMED_REGISTER_ATOMIC(&g_title_ready, "terminal_title_ready", NULL);
1379 NAMED_REGISTER_ATOMIC(&g_title_activity, "terminal_title_activity", NULL);
1380 NAMED_REGISTER_ATOMIC(&g_live, "ui_controller_live", NULL);
1381 NAMED_REGISTER_ATOMIC(&g_presentation_stop_requested, "ui_presentation_stop_requested", NULL);
1382 NAMED_REGISTER_ATOMIC(&g_blocked, "ui_controller_blocked", NULL);
1383 NAMED_REGISTER_ATOMIC(&g_lifecycle.state, "ui_controller_lifecycle", NULL);
1384 if (platform_thread_create(&g_thread, "ui_controller", presentation_main, NULL) != ASCIICHAT_OK) {
1385 ui_session_destroy(g_session);
1386 g_session = NULL;
1387 NAMED_UNREGISTER(&g_title_active);
1388 NAMED_UNREGISTER(&g_title_render);
1389 NAMED_UNREGISTER(&g_title_ready);
1390 NAMED_UNREGISTER(&g_title_activity);
1391 NAMED_UNREGISTER(&g_live);
1392 NAMED_UNREGISTER(&g_presentation_stop_requested);
1393 NAMED_UNREGISTER(&g_blocked);
1394 NAMED_UNREGISTER(&g_lifecycle.state);
1395 cond_destroy(&g_render_requested);
1396 cond_destroy(&g_render_done);
1397 mutex_destroy(&g_title_packet_mutex);
1398 mutex_destroy(&g_mutex);
1399 lifecycle_init_abort(&g_lifecycle);
1400 return SET_ERRNO(ERROR_THREAD, "Cannot start UI controller");
1401 }
1402 terminal_screen_enter(atomic_load(&g_default_fd));
1403 lifecycle_init_commit(&g_lifecycle);
1404 }
1405 if (!lifecycle_is_initialized(&g_lifecycle))
1406 return SET_ERRNO(ERROR_INVALID_STATE, "UI controller initialization is not complete");
1407 return ASCIICHAT_OK;
1408}
1409
1411 if (lifecycle_is_initialized(&g_lifecycle))
1412 mutex_lock(&g_title_packet_mutex);
1413}
1414
1416 if (lifecycle_is_initialized(&g_lifecycle))
1417 mutex_unlock(&g_title_packet_mutex);
1418}
1419
1421 if (fd < 0)
1422 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid UI terminal descriptor");
1423 atomic_store(&g_default_fd, fd);
1424 asciichat_error_t result = controller_start();
1425 return result == ASCIICHAT_OK ? ui_sync_start(fd) : result;
1426}
1427
1429 if (!mode || !platform_isatty(fd) ||
1430 (GET_OPTION(snapshot_mode) && !GET_OPTION(render_file)[0] && !GET_OPTION(render_kitty)))
1431 return ASCIICHAT_OK;
1432 if (atomic_load_bool(&g_title_active))
1433 return SET_ERRNO(ERROR_INVALID_STATE, "Terminal title is already initialized");
1434 atomic_store(&g_default_fd, fd);
1435 asciichat_error_t result = controller_start();
1436 if (result != ASCIICHAT_OK)
1437 return result;
1438 g_title_fd = platform_dup(fd);
1439 if (g_title_fd < 0)
1440 return SET_ERRNO_SYS(ERROR_TERMINAL, "Cannot retain title terminal");
1441 g_title_mode = mode;
1442 g_title_started = time_get_ns();
1443 g_last_title[0] = '\0';
1444 g_title_written = false;
1445 g_title_startup_finished = false;
1446 atomic_store_int(&g_title_render, UI_TITLE_IDLE);
1447 atomic_store_int(&g_title_activity, UI_TITLE_ACTIVITY_IDLE);
1448 atomic_store_bool(&g_title_ready, false);
1449 // Publish only after the retained descriptor and immutable configuration are ready.
1450 atomic_store_bool(&g_title_active, true);
1451 return ASCIICHAT_OK;
1452}
1453
1455 return screen >= 0 && screen < UI_SCREEN_COUNT && (atomic_load(&g_screen_mask) & (1u << screen));
1456}
1457
1458static asciichat_error_t submit_screen(ui_screen_t screen, int fd, terminal_size_t minimum, ui_render_fn render,
1459 const void *snapshot, size_t bytes, screen_terminal_t *retained_terminal,
1460 bool fresh_frame) {
1461 uint64_t entered_ns = time_get_ns();
1462 if (screen < 0 || screen >= UI_SCREEN_COUNT || !render || !snapshot || !bytes || minimum.cols <= 0 ||
1463 minimum.rows <= 0)
1464 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid UI screen snapshot");
1465 if (interactive_screen(screen) && screen != UI_SCREEN_ERRNO && screen != UI_SCREEN_HELP &&
1469 // A live slot retains its terminal even while LOG_IO redirects process stdio.
1470 bool live = lifecycle_is_initialized(&g_lifecycle) && (atomic_load(&g_screen_mask) & (1u << screen));
1471 // New modal slots inherit the live terminal instead of process stdio, which
1472 // an audio/decoder worker can temporarily redirect through LOG_IO.
1473 if (!live && !retained_terminal && lifecycle_is_initialized(&g_lifecycle) &&
1475 screen == UI_SCREEN_HELP)) {
1476 screen_terminal_t *inherited = NULL;
1477 mutex_lock(&g_mutex);
1478 int active = selected_screen_locked();
1479 if (active >= 0) {
1480 inherited = g_screens[active].owned->terminal;
1481 atomic_fetch_add(&inherited->references, 1);
1482 }
1483 mutex_unlock(&g_mutex);
1484 if (inherited) {
1485 asciichat_error_t result =
1486 submit_screen(screen, inherited->fd, minimum, render, snapshot, bytes, inherited, fresh_frame);
1487 release_terminal(inherited);
1488 return result;
1489 }
1490 }
1491 // Batch output and finite snapshots retain their synchronous output semantics.
1492 if ((!live && !retained_terminal && !platform_isatty(fd)) ||
1493 (GET_OPTION(snapshot_mode) && screen != UI_SCREEN_RENDER_PROGRESS)) {
1494 bool previous_owner = g_owner;
1495 int previous_fd = g_render_fd;
1496 terminal_size_t detected = {0};
1497 // Redirected output has no terminal geometry. Avoid raising a terminal
1498 // error for every frame when rendering to a file, pipe, or null device.
1499 if (!platform_isatty(fd) || terminal_get_size_fd(fd, &detected) != ASCIICHAT_OK || detected.cols <= 0 ||
1500 detected.rows <= 0)
1501 detected = (terminal_size_t){.cols = GET_OPTION(width), .rows = GET_OPTION(height)};
1502 g_render_size = detected;
1503 g_owner = true;
1504 g_render_fd = fd;
1506 stats_media_begin(screen == UI_SCREEN_MEDIA);
1507 render(g_render_size, snapshot);
1508 stats_media_end();
1509 g_owner = previous_owner;
1510 g_render_fd = previous_fd;
1511 return ASCIICHAT_OK;
1512 }
1513 if (!lifecycle_is_initialized(&g_lifecycle))
1514 atomic_store(&g_default_fd, fd);
1515 asciichat_error_t err = controller_start();
1516 if (err != ASCIICHAT_OK)
1517 return err;
1518 size_t payload = render == render_text ? ((const text_snapshot_t *)snapshot)->len : 0;
1519 if (bytes > SIZE_MAX - sizeof(screen_snapshot_t) || payload > SIZE_MAX - sizeof(screen_snapshot_t) - bytes)
1520 return SET_ERRNO(ERROR_MEMORY, "UI snapshot is too large");
1521 screen_snapshot_t *copy = allocate_snapshot(screen, sizeof(*copy) + bytes + payload);
1522 if (!copy)
1523 return SET_ERRNO(ERROR_MEMORY, "Cannot copy UI snapshot");
1524 memcpy(copy->data, snapshot, bytes);
1525 if (payload) {
1526 text_snapshot_t *text = (text_snapshot_t *)copy->data;
1527 text->text = copy->data + bytes;
1528 memcpy((char *)text->text, ((const text_snapshot_t *)snapshot)->text, payload);
1529 }
1530 uint64_t prepared_ns = time_get_ns();
1531 mutex_lock(&g_mutex);
1532 uint64_t locked_ns = time_get_ns();
1533 if (g_finished) {
1534 mutex_unlock(&g_mutex);
1535 release_snapshot(copy);
1536 return ASCIICHAT_OK;
1537 }
1538 copy->frame_id = screen == UI_SCREEN_MEDIA && fresh_frame ? ++g_media_frame_id : 0;
1539 copy->submitted_ns = time_get_ns();
1540 copy->acquisition_started_ns = screen == UI_SCREEN_MEDIA ? g_acquisition_started_ns : 0;
1541 screen_t *slot = &g_screens[screen];
1542 bool queue_notice = screen == UI_SCREEN_NOTICE && slot->render;
1543 if (slot->render && !queue_notice) {
1544 // The terminal belongs to the live slot, not to each incoming image.
1545 // Duplicating/closing a Windows console handle per frame introduces console
1546 // RPCs on producers and can serialize them behind ongoing presentation.
1547 copy->terminal = slot->owned->terminal;
1548 atomic_fetch_add(&copy->terminal->references, 1);
1549 } else if (retained_terminal) {
1550 copy->terminal = retained_terminal;
1551 atomic_fetch_add(&copy->terminal->references, 1);
1552 } else {
1553 int retained_fd = platform_dup(fd);
1554 if (retained_fd < 0) {
1555 mutex_unlock(&g_mutex);
1556 release_snapshot(copy);
1557 return SET_ERRNO_SYS(ERROR_FILE_OPERATION, "Cannot retain UI output descriptor");
1558 }
1559 copy->terminal = SAFE_MALLOC(sizeof(*copy->terminal), screen_terminal_t *);
1560 if (!copy->terminal) {
1561 platform_close(retained_fd);
1562 mutex_unlock(&g_mutex);
1563 release_snapshot(copy);
1564 return SET_ERRNO(ERROR_MEMORY, "Cannot retain UI terminal ownership");
1565 }
1566 atomic_init(&copy->terminal->references, 1);
1567 copy->terminal->fd = retained_fd;
1568 }
1569 int output_fd = copy->terminal->fd;
1570 err = ui_sync_start(output_fd);
1571 if (err != ASCIICHAT_OK) {
1572 mutex_unlock(&g_mutex);
1573 release_snapshot(copy);
1574 return err;
1575 }
1576 if (queue_notice) {
1577 size_t pending = 1;
1578 screen_t *tail = slot;
1579 while (tail->next) {
1580 tail = tail->next;
1581 ++pending;
1582 }
1583 if (pending >= 64) {
1584 release_snapshot(copy);
1585 mutex_unlock(&g_mutex);
1586 return SET_ERRNO(ERROR_BUFFER_FULL, "UI notice queue is full");
1587 }
1588 screen_t *queued = SAFE_CALLOC(1, sizeof(*queued), screen_t *);
1589 if (!queued) {
1590 release_snapshot(copy);
1591 mutex_unlock(&g_mutex);
1592 return SET_ERRNO(ERROR_MEMORY, "Cannot allocate queued UI notice");
1593 }
1594 *queued = (screen_t){
1595 .owned = copy, .snapshot = copy->data, .render = render, .minimum = minimum, .fd = output_fd, .dirty = true};
1596 tail->next = queued;
1597 mutex_unlock(&g_mutex);
1598 return ASCIICHAT_OK;
1599 }
1600 screen_snapshot_t *retired = slot->owned;
1601 atomic_store_bool(&g_live, true);
1603 *slot = (screen_t){
1604 .owned = copy, .snapshot = copy->data, .render = render, .minimum = minimum, .fd = output_fd, .dirty = true};
1605 if (interactive_screen(screen) && screen != UI_SCREEN_STATS && (screen != UI_SCREEN_MDNS || !live) &&
1609 publish_screen_locked();
1610 mutex_unlock(&g_mutex);
1611 uint64_t published_ns = time_get_ns();
1612 release_snapshot(retired);
1613 uint64_t retired_ns = time_get_ns();
1614 uint64_t finished_ns = time_get_ns();
1615 if (screen == UI_SCREEN_MEDIA && finished_ns - entered_ns > 50 * NS_PER_MS_INT)
1617 US_PER_SEC_INT, "UI_SUBMIT_STAGE: build_ms=%.2f lock_ms=%.2f publish_ms=%.2f retire_ms=%.2f",
1618 (double)(prepared_ns - entered_ns) / NS_PER_MS_INT, (double)(locked_ns - prepared_ns) / NS_PER_MS_INT,
1619 (double)(published_ns - locked_ns) / NS_PER_MS_INT, (double)(retired_ns - published_ns) / NS_PER_MS_INT);
1620 return ASCIICHAT_OK;
1621}
1622
1624 const void *snapshot, size_t bytes) {
1625 return submit_screen(screen, fd, minimum, render, snapshot, bytes, NULL, true);
1626}
1627
1628static void remove_screen(ui_screen_t screen, int previous, screen_snapshot_t *expected_owned) {
1629 if (screen < 0 || screen >= UI_SCREEN_COUNT || !lifecycle_is_initialized(&g_lifecycle))
1630 return;
1631 mutex_lock(&g_mutex);
1632 if (expected_owned && g_screens[screen].owned != expected_owned) {
1633 mutex_unlock(&g_mutex);
1634 return;
1635 }
1636 // An old callback must not remove a replacement submitted while it rendered.
1637 if (g_render_snapshot && g_rendering_screen == (int)screen && g_screens[screen].owned != g_render_snapshot) {
1638 mutex_unlock(&g_mutex);
1639 return;
1640 }
1641 screen_snapshot_t *retired = g_screens[screen].owned;
1642 // Restore focus before retiring the modal so underlying workers never see an
1643 // intermediate empty selection. A departed parent must not regain input.
1644 if (previous < 0 || previous >= UI_SCREEN_COUNT || previous == (int)screen || !interactive_screen(previous) ||
1645 (previous != UI_SCREEN_SYNC && !g_screens[previous].render))
1646 previous = -1;
1647 int expected = screen;
1648 atomic_compare_exchange_strong(&g_interactive_screen, &expected, previous);
1649 uint64_t pending = (g_rendering_layers & (1u << screen)) ? g_render_started : 0;
1650 screen_t *next = g_screens[screen].next;
1651 if (next) {
1652 g_screens[screen] = *next;
1653 SAFE_FREE(next);
1654 g_redraw = true;
1655 } else {
1656 memset(&g_screens[screen], 0, sizeof(screen_t));
1657 }
1658 publish_screen_locked();
1659 bool live = g_rendering_screen >= 0;
1660 for (int i = 0; i < UI_SCREEN_COUNT; ++i)
1661 live |= g_screens[i].render != NULL;
1662 atomic_store_bool(&g_live, live);
1663 // Borrowed pointers inside snapshots may be freed as soon as remove returns.
1664 // A callback removing itself retains its owned bytes until the frame ends.
1665 while (!g_render_snapshot && g_render_completed < pending)
1666 cond_wait(&g_render_done, &g_mutex);
1667 live = g_rendering_screen >= 0;
1668 for (int i = 0; i < UI_SCREEN_COUNT; ++i)
1669 live |= g_screens[i].render != NULL;
1670 if (!live) {
1671 if (!g_render_snapshot && shutdown_is_requested())
1672 ui_error_popup_preserve(atomic_load(&g_default_fd), unpack_size(atomic_load(&g_size)));
1674 }
1675 mutex_unlock(&g_mutex);
1676 release_snapshot(retired);
1677}
1678
1680 remove_screen(screen, -1, NULL);
1681}
1682
1684 remove_screen(screen, previous, NULL);
1685}
1686
1688 if (!lifecycle_is_initialized(&g_lifecycle))
1689 return false;
1690 mutex_lock(&g_mutex);
1691 screen_snapshot_t *owned = g_screens[UI_SCREEN_NOTICE].owned;
1692 if (owned)
1693 atomic_fetch_add(&owned->references, 1);
1694 mutex_unlock(&g_mutex);
1695 if (!owned)
1696 return false;
1697 ui_notice_key_result_t result = ui_notice_first_use_key(owned->data, key);
1698 if (result == UI_NOTICE_KEY_HELP)
1700 if (result == UI_NOTICE_KEY_COMPLETE)
1701 ui_notice_first_use_complete(owned->data);
1702 if (result == UI_NOTICE_KEY_COMPLETE || result == UI_NOTICE_KEY_DISMISS)
1703 remove_screen(UI_SCREEN_NOTICE, -1, owned);
1704 else if (result == UI_NOTICE_KEY_HANDLED && (key == '\r' || key == '\n' || key == KEY_LEFT || key == KEY_RIGHT))
1706 release_snapshot(owned);
1707 return result != UI_NOTICE_KEY_ORDINARY;
1708}
1709
1711 ui_presentation_state_t state = {.screen = -1};
1712 if (!lifecycle_is_initialized(&g_lifecycle))
1713 return state;
1715 if (state.screen == UI_SCREEN_ERROR) {
1717 return state;
1718 }
1719 if (state.screen >= 0 && state.screen < UI_SCREEN_COUNT) {
1720 uint64_t published = atomic_load(&g_input_state);
1721 // Sync has an independent mailbox instead of a controller slot. Treat an
1722 // unpublished slot as absent so the session can dispatch queued switches.
1723 if (!(published & (UINT64_C(1) << state.screen)))
1724 state.screen = -1;
1725 else
1726 state.covered = (published & (UINT64_C(1) << (state.screen + 32))) != 0;
1727 }
1728 return state;
1729}
1730
1731// Bound REP expansion to one terminal row before allocating its expanded text.
1732static char *expand_media_row(const char *row, size_t length, int columns) {
1733 size_t repeats = 0;
1734 for (const char *p = row, *end = row + length; p < end;) {
1735 if (*p != '\033') {
1736 ++p;
1737 continue;
1738 }
1739 const char *next = ansi_skip_escape(p, end);
1740 if (next <= p)
1741 return NULL;
1742 if (next - p >= 3 && p[1] == '[' && next[-1] == 'b') {
1743 size_t count = 0;
1744 for (const char *digit = p + 2; digit < next - 1; ++digit) {
1745 if (*digit < '0' || *digit > '9' || count > (size_t)columns / 10)
1746 return NULL;
1747 count = count * 10 + (size_t)(*digit - '0');
1748 if (count > (size_t)columns)
1749 return NULL;
1750 }
1751 if (!count || count > (size_t)columns - repeats)
1752 return NULL;
1753 repeats += count;
1754 }
1755 p = next;
1756 }
1757 return ansi_expand_rle(row, length);
1758}
1759
1760static void render_text(terminal_size_t size, const void *data) {
1761 const text_snapshot_t *text = data;
1762 if (text->media && !GET_OPTION(snapshot_mode) && GET_OPTION(fps_counter) && size.cols >= 7 &&
1763 text->dimensions.cols <= size.cols && text->dimensions.rows <= size.rows) {
1764 const char *newline = memchr(text->text, '\n', text->len);
1765 size_t first_bytes = newline ? (size_t)(newline - text->text) : text->len;
1766 // Expand REP before clipping, so a repeated glyph cannot cross the counter.
1767 char *first = expand_media_row(text->text, first_bytes, size.cols);
1768 if (first) {
1769 size_t prefix = utf8_prefix_bytes_for_width(first, strlen(first), size.cols - 7);
1770 ui_controller_write(text->fd, first, prefix);
1771 SAFE_FREE(first);
1772 } else {
1773 ui_controller_printf(text->fd, "\033[1;1H\033[%dX", size.cols - 7);
1774 }
1775 if (newline) {
1776 ui_controller_write(text->fd, "\033[0m\033[2;1H", 10);
1777 ui_controller_write(text->fd, newline + 1, text->len - first_bytes - 1);
1778 }
1779 if (g_render_volume)
1780 append_volume(g_capture, size);
1781 return;
1782 }
1783 if (!text->media || GET_OPTION(snapshot_mode) ||
1784 (text->dimensions.cols <= size.cols && text->dimensions.rows <= size.rows) ||
1785 !platform_isatty(g_render_fd >= 0 ? g_render_fd : text->fd)) {
1786 if (text->media && g_render_volume) {
1787 frame_buffer_t *buffer = frame_buffer_create(size.rows, size.cols);
1788 if (buffer) {
1789 frame_buffer_append(buffer, text->text, text->len);
1790 append_volume(buffer, size);
1792 frame_buffer_destroy(buffer);
1793 } else {
1794 ui_controller_write(text->fd, text->text, text->len);
1795 }
1796 } else {
1797 ui_controller_write(text->fd, text->text, text->len);
1798 }
1799 return;
1800 }
1801 // If only the height exceeds the terminal, every visible row already fits.
1802 // Preserve its SGR and REP sequences instead of expanding and clipping it.
1803 bool counter = GET_OPTION(fps_counter) && size.cols >= 7;
1804 if (text->dimensions.cols > 0 && text->dimensions.cols < size.cols &&
1805 (!counter || text->dimensions.cols <= size.cols - 7)) {
1806 const char *cursor = text->text;
1807 const char *end = cursor + text->len;
1808 for (int row = 1; row <= size.rows && cursor < end; ++row) {
1809 const char *newline = memchr(cursor, '\n', (size_t)(end - cursor));
1810 size_t length = (size_t)((newline ? newline : end) - cursor);
1811 ui_controller_printf(text->fd, "\033[%d;1H", row);
1812 ui_controller_write(text->fd, cursor, length);
1813 // Keep the old row visible until its replacement has been written.
1814 // The FPS counter owns the right edge of the first row.
1815 if (row != 1 || !counter)
1816 ui_controller_write(text->fd, "\033[K", 3);
1817 cursor += length + (newline ? 1 : 0);
1818 }
1819 if (g_render_volume)
1820 append_volume(g_capture, size);
1821 return;
1822 }
1823 // A paused frame can outlive its original dimensions. Clip its rows instead
1824 // of letting old-sized content wrap and scroll after a terminal resize.
1825 frame_buffer_t *buffer = frame_buffer_create(size.rows, size.cols);
1826 char *line = SAFE_MALLOC(text->len + 1, char *);
1827 char *clipped = SAFE_MALLOC(text->len + UI_CLIPPED_TEXT_OVERHEAD, char *);
1828 if (buffer && line && clipped) {
1829 const char *cursor = text->text;
1830 const char *end = cursor + text->len;
1831 for (int row = 1; row <= size.rows && cursor < end; ++row) {
1832 const char *newline = memchr(cursor, '\n', (size_t)(end - cursor));
1833 size_t length = (size_t)((newline ? newline : end) - cursor);
1834 memcpy(line, cursor, length);
1835 line[length] = '\0';
1836 bool reserved_counter = row == 1 && GET_OPTION(fps_counter) && size.cols >= 7;
1837 char *expanded = expand_media_row(line, length, text->dimensions.cols);
1838 truncate_with_ellipsis(expanded ? expanded : "", clipped, text->len + UI_CLIPPED_TEXT_OVERHEAD,
1839 size.cols - (reserved_counter ? 7 : 1));
1840 SAFE_FREE(expanded);
1841 frame_buffer_printf(buffer, "\033[%d;1H", row);
1842 frame_buffer_append(buffer, clipped, strlen(clipped));
1843 if (!reserved_counter)
1844 frame_buffer_append(buffer, "\033[K", 3);
1845 cursor += length + (newline ? 1 : 0);
1846 }
1847 if (g_render_volume)
1848 append_volume(buffer, size);
1850 }
1851 if (buffer)
1852 frame_buffer_destroy(buffer);
1853 SAFE_FREE(line);
1854 SAFE_FREE(clipped);
1855}
1856
1858 if (!lifecycle_is_initialized(&g_lifecycle))
1859 return;
1860 mutex_lock(&g_mutex);
1861 g_redraw = true;
1862 cond_signal(&g_render_requested);
1863 mutex_unlock(&g_mutex);
1864}
1865
1866static asciichat_error_t present_text(ui_screen_t screen, int fd, terminal_size_t minimum, const char *data, size_t len,
1867 bool fresh) {
1868 uint64_t entered_ns = time_get_ns();
1869 if (!data || !len || len > UI_CONTROLLER_MAX_FRAME_BYTES)
1870 return SET_ERRNO(ERROR_INVALID_PARAM, "Invalid UI frame");
1871 text_snapshot_t text = {.fd = fd,
1872 .media = screen == UI_SCREEN_MEDIA,
1873 .dimensions = {.cols = GET_OPTION(width), .rows = GET_OPTION(height)},
1874 .len = len,
1875 .text = data};
1876 uint64_t prepared_ns = time_get_ns();
1877 asciichat_error_t err = submit_screen(screen, fd, minimum, render_text, &text, sizeof(text), NULL, fresh);
1878 uint64_t submitted_ns = time_get_ns();
1879 uint64_t finished_ns = time_get_ns();
1880 if (screen == UI_SCREEN_MEDIA && finished_ns - entered_ns > 50 * NS_PER_MS_INT)
1881 log_debug_every(US_PER_SEC_INT, "UI_TEXT_STAGE: prepare_ms=%.2f submit_ms=%.2f free_ms=%.2f",
1882 (double)(prepared_ns - entered_ns) / NS_PER_MS_INT,
1883 (double)(submitted_ns - prepared_ns) / NS_PER_MS_INT,
1884 (double)(finished_ns - submitted_ns) / NS_PER_MS_INT);
1885 return err;
1886}
1887
1889 if (!lifecycle_destroy_once(&g_lifecycle))
1890 return;
1891 mutex_lock(&g_mutex);
1892 atomic_store_bool(&g_presentation_stop_requested, true);
1893 cond_signal(&g_render_requested);
1894 mutex_unlock(&g_mutex);
1895 thread_join(&g_thread, NULL);
1896 // Shutdown may precede the next presentation tick. Keep pending errors readable
1897 // after exit without delaying transport teardown or waiting for terminal input.
1898 ui_error_popup_preserve(atomic_load(&g_default_fd), unpack_size(atomic_load(&g_size)));
1899 ui_session_destroy(g_session);
1900 g_session = NULL;
1903 atomic_store_u64(&g_volume_until, 0);
1904 ui_sync_stop();
1905 for (int i = 0; i < UI_SCREEN_COUNT; ++i) {
1906 release_screen(&g_screens[i], i == UI_SCREEN_NOTICE);
1907 }
1908 destroy_media_snapshots();
1909 cond_destroy(&g_render_requested);
1910 cond_destroy(&g_render_done);
1911 mutex_destroy(&g_title_packet_mutex);
1912 mutex_destroy(&g_mutex);
1913 atomic_store_bool(&g_live, false);
1914 atomic_store(&g_background_screen, -1);
1915 atomic_store(&g_screen_mask, 0);
1917 terminal_screen_leave();
1918 NAMED_UNREGISTER(&g_title_active);
1919 NAMED_UNREGISTER(&g_title_render);
1920 NAMED_UNREGISTER(&g_title_ready);
1921 NAMED_UNREGISTER(&g_title_activity);
1922 NAMED_UNREGISTER(&g_live);
1923 NAMED_UNREGISTER(&g_presentation_stop_requested);
1924 NAMED_UNREGISTER(&g_blocked);
1925 NAMED_UNREGISTER(&g_lifecycle.state);
1926 lifecycle_destroy_commit(&g_lifecycle);
1927}
1928
1929asciichat_error_t ui_controller_printf(int fd, const char *format, ...) {
1931 va_list args;
1932 va_start(args, format);
1933 int len = vsnprintf(buffer, sizeof(buffer), format, args);
1934 va_end(args);
1935 if (len < 0)
1936 return SET_ERRNO(ERROR_INVALID_PARAM, "Cannot format terminal output");
1937 size_t bytes = (size_t)len < sizeof(buffer) ? (size_t)len : sizeof(buffer) - 1;
1938 return ui_controller_write(fd, buffer, bytes);
1939}
1940
1943 if (platform_isatty(STDOUT_FILENO))
1944 ui_controller_write(STDOUT_FILENO, "\033[0m\033[?25h", 10);
1945}
1946
1947static void render_logs(terminal_size_t size, const void *snapshot) {
1948 (void)snapshot;
1949 ui_session_logs_render(g_capture, size, g_session);
1950}
1951
1956
1961 return;
1962 }
1963 if (!platform_isatty(STDOUT_FILENO) || GET_OPTION(snapshot_mode))
1964 return;
1965 const bool snapshot = true;
1966 asciichat_error_t result =
1967 ui_controller_submit(UI_SCREEN_LOGS, STDOUT_FILENO, (terminal_size_t){.cols = 40, .rows = 6}, render_logs,
1968 &snapshot, sizeof(snapshot));
1969 if (result != ASCIICHAT_OK)
1970 log_warn("Cannot show application logs: %d", result);
1971}
1972
1973asciichat_error_t ui_controller_present(ui_screen_t screen, int fd, terminal_size_t minimum, const char *data,
1974 size_t len) {
1975 return present_text(screen, fd, minimum, data, len, true);
1976}
1977asciichat_error_t ui_controller_present_retained(ui_screen_t screen, int fd, terminal_size_t minimum, const char *data,
1978 size_t len) {
1979 return present_text(screen, fd, minimum, data, len, false);
1980}
action_args_t args
Definition actions.c:78
ANSI escape sequence utilities and fast color code generation.
void errno_scope_end(asciichat_errno_scope_t scope, asciichat_errno_outcome_t outcome)
void errno_notifications_pause(void)
bool errno_notifications_suppress(bool suppress)
asciichat_errno_scope_t errno_scope_begin(void)
void errno_notifications_enable(bool enabled)
size_t errno_title_snapshot(asciichat_error_context_t *latest)
#define SET_ERRNO(code,...)
#define SET_ERRNO_SYS(code,...)
#define CLEAR_ERRNO()
@ ASCIICHAT_ERRNO_DISMISSED
⚙️ Common definitions, error codes, macros, and types shared throughout the application
Named object registry for debugging — log identifiable resource names.
void ui_errno_stacks_hide(void)
bool ui_error_popup_visible(void)
Definition error_popup.c:71
void ui_error_popup_preserve(int fd, terminal_size_t size)
uint64_t ui_error_popup_revision(void)
Definition error_popup.c:75
void ui_error_popup_reset(void)
void ui_error_popup_render(frame_buffer_t *out, terminal_size_t size)
terminal_size_t ui_error_popup_minimum(void)
Definition error_popup.c:83
int color_mode
Definition fft.c:25
char cells[(512 *256 *2)]
Definition fft.c:21
unsigned int height
Definition fft.c:23
unsigned int width
Definition fft.c:23
bool fps_counter_frame_begin_id(fps_counter_t *counter, uint64_t frame_id)
⏱️ FPS counter for measuring output write throughput
void frame_buffer_append(frame_buffer_t *buf, const char *str, size_t len)
Append raw bytes to the buffer.
void frame_buffer_set_screen_output_fd(int fd)
Set output file descriptor for terminal screen (splash/status) rendering.
void frame_buffer_destroy(frame_buffer_t *buf)
Destroy a frame buffer.
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.
void frame_buffer_clear_screen(frame_buffer_t *buf)
Append clear screen and home escape codes (\033[2J\033[H).
struct frame_buffer frame_buffer_t
Opaque frame buffer handle.
const char * ansi_skip_escape(const char *ptr, const char *end)
Find the end of an ANSI CSI escape sequence.
Definition ansi.c:78
#define SAFE_REALLOC(ptr, size, cast)
Definition common.h:285
unsigned int uint32_t
Definition common.h:62
#define SAFE_FREE(ptr)
Definition common.h:377
#define SAFE_MALLOC(size, cast)
Definition common.h:265
#define SAFE_GETENV(name)
Definition common.h:449
#define SAFE_CALLOC(count, size, cast)
Definition common.h:275
unsigned long long uint64_t
Definition common.h:63
#define NAMED_REGISTER_ATOMIC(a, name, parent_ptr)
Register an atomic_t with automatic format specifier.
#define NAMED_UNREGISTER(ptr)
Unregister a pointer.
asciichat_error_t
Error and exit codes - unified status values (0-255).
Definition error_codes.h:44
@ ERROR_INVALID_STATE
@ ERROR_BUFFER_FULL
@ ERROR_MEMORY
Definition error_codes.h:51
@ ASCIICHAT_OK
Definition error_codes.h:46
@ ERROR_FILE_OPERATION
@ ERROR_INVALID_PARAM
@ ERROR_TERMINAL
Definition error_codes.h:64
@ ERROR_THREAD
#define log_warn(...)
Log a WARN message.
Definition log/log.h:621
log_color_t
Color enum for logging - indexes into color arrays.
Definition log/log.h:147
#define log_error(...)
Log an ERROR message.
Definition log/log.h:634
@ LOG_COLOR_GREY
Definition log/log.h:154
@ LOG_COLOR_ERROR
Definition log/log.h:152
@ LOG_COLOR_INFO
Definition log/log.h:150
@ LOG_COLOR_WARN
Definition log/log.h:151
#define NS_PER_SEC_INT
Definition time.h:169
#define NS_PER_MS_INT
Definition time.h:168
#define US_PER_SEC_INT
Definition time.h:173
utf8_setting_t
UTF-8 support setting (–utf8 flag values).
Definition options.h:262
#define GET_OPTION(field)
Safely get a specific option field (lock-free read).
Definition options.h:1336
asciichat_error_t options_set_int(const char *name, int value)
Check if an option was explicitly set via command-line.
Definition rcu.c:894
color_setting_t
For options state, this is the live record that ties detected_mode, mode_arg_index,...
Definition options.h:243
#define options_terminal_settings()
Definition options.h:1296
@ COLOR_SETTING_FALSE
Force colors OFF: disable all colors and logging colors.
Definition options.h:249
size_t platform_write_all(int fd, const void *buf, size_t count)
Write all bytes to a file descriptor, handling partial writes.
int platform_dup(int fd)
void platform_sleep_ms(unsigned int ms)
Sleep for a specified number of milliseconds.
@ KEY_LEFT
Left arrow key.
Definition keyboard.h:59
@ KEY_RIGHT
Right arrow key.
Definition keyboard.h:60
void keyboard_help_toggle_terminal(int fd)
void fps_counter_reset(fps_counter_t *counter)
Definition fps_counter.c:82
void fps_counter_frame_end(fps_counter_t *counter, uint64_t now)
void fps_counter_write_begin(fps_counter_t *counter)
int keyboard_help_parent_screen(void)
void fps_counter_set_effective_fps(fps_counter_t *counter, unsigned int fps)
bool fps_counter_set_visible(fps_counter_t *counter, bool visible)
void fps_counter_destroy(fps_counter_t *counter)
Destroy FPS counter and free resources.
Definition fps_counter.c:68
bool update_banner_is_checking(void)
void fps_counter_render(fps_counter_t *counter, int fd, int columns, bool redrawn)
void fps_counter_write_end(fps_counter_t *counter, bool complete)
struct fps_counter_s fps_counter_t
Opaque FPS counter handle.
Definition fps_counter.h:40
const char * keyboard_help_screen_name(int parent)
fps_counter_t * fps_counter_create(void)
Create a new FPS counter.
Definition fps_counter.c:50
bool shutdown_is_requested(void)
Check if shutdown has been requested.
Definition common.c:86
void truncate_with_ellipsis(const char *input, char *output, size_t output_size, int max_width)
Truncate a string to a maximum display width, with ellipsis.
Definition string.c:370
char * ansi_expand_rle(const char *input, size_t input_len)
Expand RLE escape sequences in a string.
Definition rle.c:14
struct ui_session ui_session_t
uint64_t keyboard_stream_terminal_reply_ns(void)
int keyboard_help_previous_interactive(void)
🆘 Interactive keyboard help overlay for session keyboard shortcuts
void stats_counter_add(stats_scope_t *scope, stats_counter_id_t id, uint64_t amount)
void stats_duration_record(stats_scope_t *scope, stats_duration_id_t id, uint64_t elapsed_ns)
void ui_session_redraw(ui_session_t *s)
void ui_session_resize(ui_session_t *s, int cols, int rows)
int ui_session_select_screen(uint32_t visible_screens)
const char * ui_session_render(ui_session_t *s, const ui_session_view_t *view, uint64_t now_ns)
size_t ui_session_frame_length(const ui_session_t *s)
void ui_session_destroy(ui_session_t *s)
uint64_t ui_session_next_frame_ns(const ui_session_t *s)
bool ui_session_is_covered(const ui_session_t *s)
ui_session_t * ui_session_create(void)
void ui_session_logs_render(frame_buffer_t *output, terminal_size_t size, ui_session_t *s)
#define log_debug_every(interval_us, fmt,...)
Rate-limited DEBUG logging.
Definition log/log.h:749
void ui_notice_flush_snapshot(int fd, const void *data)
Definition notice.c:526
ui_notice_key_result_t ui_notice_first_use_key(const void *data, int key)
Definition notice.c:453
void ui_notice_frame_presented(const void *data)
Definition notice.c:447
const char * ui_notice_title(const void *data)
Definition notice.c:194
void ui_notice_first_use_complete(const void *data)
Definition notice.c:478
ui_notice_key_result_t
Definition notice.h:30
@ UI_NOTICE_KEY_HELP
Definition notice.h:33
@ UI_NOTICE_KEY_ORDINARY
Definition notice.h:31
@ UI_NOTICE_KEY_DISMISS
Definition notice.h:34
@ UI_NOTICE_KEY_COMPLETE
Definition notice.h:35
@ UI_NOTICE_KEY_HANDLED
Definition notice.h:32
bool auto_height
bool auto_width
⚙️ Unified options parsing system for ascii-chat with builder pattern and lock-free access
void ascii_otel_ui_usage(const char *screen, const char *event, uint64_t duration_ns)
Definition otel.c:434
void ui_output_set_writer(ui_output_write_fn writer)
Definition output.c:13
const char * ui_spinner_glyph(uint64_t elapsed_ns, bool use_colors)
Definition progress.c:34
const char * ui_prompt_title(const void *data)
Definition prompt.c:183
const char * ui_prompt_usage_name(const void *data)
Definition prompt.c:228
int ui_prompt_required_rows(const void *data, int cols)
Definition prompt.c:311
uint64_t options_revision(void)
Definition rcu.c:876
stats_scope_t * stats_runtime_scope(void)
Definition runtime.c:80
void terminal_screen_logs_render_help(frame_buffer_t *output, terminal_size_t size)
Shutdown check system for clean library/application separation.
🔤 String Manipulation and Shell Escaping Utilities
For animation config, this is the live record that ties type, fps, speed, hidden_policy together....
When code needs to work with asciichat errno snapshot, it keeps total, copied here instead of scatter...
screen_snapshot_t * snapshot
screen_t * layers
screen_t * screen
When code needs to work with media snapshot slot, it keeps busy, snapshot, capacity here instead of s...
screen_terminal_t * terminal
uint64_t acquisition_started_ns
media_snapshot_slot_t * slot
_Atomic unsigned references
_Atomic unsigned references
ui_render_fn render
screen_snapshot_t * owned
struct screen * next
void * snapshot
terminal_size_t minimum
unsigned page
const char * text
terminal_size_t dimensions
void ui_sync_stop(void)
Definition sync.c:339
asciichat_error_t ui_sync_start(int fd)
Definition sync.c:311
void ui_sync_render(terminal_size_t size)
Definition sync.c:486
bool ui_sync_is_visible(void)
Definition sync.c:357
int ui_sync_output_fd(void)
Definition sync.c:360
bool terminal_screen_logs_help_open(void)
void terminal_screen_logs_reset(void)
Reusable "fixed header + scrolling logs" terminal screen abstraction.
const char * ui_text_color_prefix(log_color_t color)
Definition text.c:18
#define UI_CLIPPED_TEXT_OVERHEAD
Definition text.h:4
⏱️ High-precision timing utilities using sokol_time.h and uthash
void ui_too_small_render(frame_buffer_t *buffer, terminal_size_t actual, terminal_size_t minimum)
Definition too_small.c:38
bool ui_too_small(terminal_size_t actual, terminal_size_t minimum)
Definition too_small.c:10
#define UI_MEDIA_MIN_COLS
Definition too_small.h:5
#define UI_MEDIA_MIN_ROWS
Definition too_small.h:6
void ui_controller_packet_begin(void)
asciichat_error_t ui_controller_start_terminal(int fd)
void ui_controller_remove(ui_screen_t screen)
bool ui_controller_first_use_key(int key)
#define TUI_CONTROLLER_BUFFER_BUFFER_SIZE
void ui_controller_dismiss(ui_screen_t screen, int previous)
void ui_controller_title_render(ui_title_render_t phase)
struct screen screen_t
void ui_controller_select_interactive(int screen)
asciichat_error_t ui_controller_submit(ui_screen_t screen, int fd, terminal_size_t minimum, ui_render_fn render, const void *snapshot, size_t bytes)
#define TUI_CONTROLLER_TITLE_BUFFER_SIZE
void ui_controller_set_effective_fps(unsigned int fps)
bool ui_controller_is_presenting(void)
void ui_controller_redraw(void)
#define TUI_CONTROLLER_LAST_TITLE_BUFFER_SIZE
asciichat_error_t ui_controller_title_start(const char *mode, int fd)
void ui_controller_frame_acquired_at(uint64_t started_ns)
bool ui_controller_is_owner(void)
void ui_controller_restore_terminal(void)
const animation_sample_t * ui_controller_animation_sample(void)
void ui_controller_toggle_logs(void)
void ui_controller_title_activity(ui_title_activity_t activity)
int ui_controller_current_screen(void)
void ui_controller_packet_end(void)
void ui_controller_finish(int fd, const char *data, size_t len)
terminal_size_t ui_controller_size(void)
#define TUI_LAYER_CAPACITY
void ui_controller_title_ready(void)
void ui_controller_hide_logs(void)
void ui_controller_show_volume(void)
struct media_snapshot_slot media_snapshot_slot_t
void ui_controller_shutdown(void)
int ui_controller_interactive_screen(void)
size_t ui_controller_trim_utf8_chunk(const char *data, size_t length)
bool ui_controller_is_blocked(void)
asciichat_error_t ui_controller_write(int fd, const char *data, size_t len)
asciichat_error_t ui_controller_present_retained(ui_screen_t screen, int fd, terminal_size_t minimum, const char *data, size_t len)
#define TUI_MEDIA_SNAPSHOT_CAPACITY
ui_presentation_state_t ui_controller_state(void)
bool ui_controller_screen_live(ui_screen_t screen)
void ui_controller_title_discovery(bool has_session, bool joining)
asciichat_error_t ui_controller_present(ui_screen_t screen, int fd, terminal_size_t minimum, const char *data, size_t len)
int ui_controller_active_fd(void)
asciichat_error_t ui_controller_printf(int fd, const char *format,...)
ui_title_activity_t
@ UI_TITLE_CONNECTING
@ UI_TITLE_WAITING_PEERS
@ UI_TITLE_CREATING_SESSION
@ UI_TITLE_ACTIVITY_IDLE
@ UI_TITLE_RECONNECTING
@ UI_TITLE_JOINING_SESSION
#define UI_CONTROLLER_MAX_FRAME_BYTES
ui_screen_t
@ UI_SCREEN_PROMPT
@ UI_SCREEN_COUNT
@ UI_SCREEN_NOTICE
@ UI_SCREEN_LOGS
@ UI_SCREEN_ERRNO
@ UI_SCREEN_MEDIA
@ UI_SCREEN_STATS
@ UI_SCREEN_UPDATE
@ UI_SCREEN_SYNC
@ UI_SCREEN_SPLASH
@ UI_SCREEN_STATUS
@ UI_SCREEN_MDNS
@ UI_SCREEN_HELP
@ UI_SCREEN_ERROR
@ UI_SCREEN_RENDER_PROGRESS
ui_title_render_t
@ UI_TITLE_FINALIZING
@ UI_TITLE_RENDERING
@ UI_TITLE_FAILED
@ UI_TITLE_IDLE
void(* ui_render_fn)(terminal_size_t size, const void *snapshot)
Update available prompt screen shown after splash.
size_t utf8_prefix_bytes_for_width(const char *str, size_t max_bytes, int max_width)
Return the byte length of a prefix fitting a terminal column limit.
Definition utf8.c:93
🔤 UTF-8 Encoding and Decoding Utilities