diff --git a/misrc_tools/meson.build b/misrc_tools/meson.build index 080519d..bbb88b5 100644 --- a/misrc_tools/meson.build +++ b/misrc_tools/meson.build @@ -401,6 +401,7 @@ if raylib_dep.found() 'misrc_gui/visualization/gui_vu_meter.c', 'misrc_gui/visualization/panel_registry.c', # UI + 'misrc_gui/ui/gui_ui_scale.c', 'misrc_gui/ui/gui_ui.c', 'misrc_gui/ui/gui_dropdown.c', 'misrc_gui/ui/gui_popup.c', diff --git a/misrc_tools/misrc_gui/core/gui_app.h b/misrc_tools/misrc_gui/core/gui_app.h index e05ce3c..3e716b6 100644 --- a/misrc_tools/misrc_gui/core/gui_app.h +++ b/misrc_tools/misrc_gui/core/gui_app.h @@ -281,6 +281,7 @@ typedef struct { bool show_grid; float time_scale; // Time per division (ms) float amplitude_scale; // Amplitude scale factor + int ui_scale_percent; // Ctrl/Cmd+wheel or +/- UI zoom, persisted as 75-200 // Device discovery: V4L2/simple_capture device enumeration is opt-in. // Disabled by default since most users use hsdaoh/CXADC/DdD/FX3 backends; diff --git a/misrc_tools/misrc_gui/core/gui_settings.c b/misrc_tools/misrc_gui/core/gui_settings.c index 435ba7f..0b9d8f5 100644 --- a/misrc_tools/misrc_gui/core/gui_settings.c +++ b/misrc_tools/misrc_gui/core/gui_settings.c @@ -5,6 +5,7 @@ */ #include "../core/gui_app.h" +#include "../ui/gui_ui_scale.h" #include #include #include @@ -465,6 +466,7 @@ void gui_settings_init_defaults(gui_settings_t *settings) { settings->show_grid = true; settings->time_scale = 1.0f; settings->amplitude_scale = 1.0f; + settings->ui_scale_percent = GUI_UI_SCALE_DEFAULT_PERCENT; // V4L2/simple_capture device discovery is opt-in (disabled by default). settings->discover_simple_capture = false; @@ -603,6 +605,7 @@ void gui_settings_save(const gui_settings_t *settings) { fprintf(f, " \"show_grid\": %s,\n", settings->show_grid ? "true" : "false"); fprintf(f, " \"time_scale\": %.2f,\n", settings->time_scale); fprintf(f, " \"amplitude_scale\": %.2f,\n", settings->amplitude_scale); + fprintf(f, " \"ui_scale_percent\": %d,\n", settings->ui_scale_percent); fprintf(f, " \"discover_simple_capture\": %s,\n", settings->discover_simple_capture ? "true" : "false"); fprintf(f, " \"show_core_pinning_in_settings\": %s,\n", settings->show_core_pinning_in_settings ? "true" : "false"); fprintf(f, " \"memory_budget_gb\": %u,\n", (unsigned)settings->memory_budget_gb); @@ -1032,6 +1035,9 @@ void gui_settings_load(gui_settings_t *settings) { if ((value = find_value(content, "amplitude_scale")) != NULL) { settings->amplitude_scale = (float)atof(value); } + if ((value = find_value(content, "ui_scale_percent")) != NULL) { + settings->ui_scale_percent = gui_ui_scale_parse_percent(value); + } if ((value = find_value(content, "discover_simple_capture")) != NULL) { settings->discover_simple_capture = (strcmp(value, "true") == 0); } diff --git a/misrc_tools/misrc_gui/core/misrc_gui.c b/misrc_tools/misrc_gui/core/misrc_gui.c index 17c8c79..84769ac 100644 --- a/misrc_tools/misrc_gui/core/misrc_gui.c +++ b/misrc_tools/misrc_gui/core/misrc_gui.c @@ -36,6 +36,7 @@ #include #include #include +#include #if defined(__APPLE__) #include #include @@ -68,6 +69,7 @@ volatile atomic_int do_exit = 0; // Font array for Clay // Index 0: Inter (general UI), Index 1: Space Mono (monospace sections) #define FONT_COUNT 2 +#define UI_FONT_ATLAS_SIZE 64 static Font fonts[FONT_COUNT]; // Clay error handler @@ -97,28 +99,6 @@ static void print_usage(const char *program_name) { fprintf(stdout, "(Headless CLI capture mode is not available in the Android build.)\n"); #endif } -static int gui_layout_width(void) { -#if defined(__APPLE__) - int width = GetScreenWidth(); -#else - int width = GetRenderWidth(); - if (width <= 0) { - width = GetScreenWidth(); - } -#endif - return (width > 0) ? width : 1; -} -static int gui_layout_height(void) { -#if defined(__APPLE__) - int height = GetScreenHeight(); -#else - int height = GetRenderHeight(); - if (height <= 0) { - height = GetScreenHeight(); - } -#endif - return (height > 0) ? height : 1; -} static bool gui_status_is_permission_denied(const gui_app_t *app) { if (!app) return false; return strstr(app->status_message, "Permission denied") != NULL || @@ -126,6 +106,15 @@ static bool gui_status_is_permission_denied(const gui_app_t *app) { strstr(app->status_message, "device not granted") != NULL || strstr(app->status_message, "USB open failed") != NULL; } + +static bool gui_primary_modifier_down(void) +{ + bool down = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL); +#if defined(__APPLE__) + down = down || IsKeyDown(KEY_LEFT_SUPER) || IsKeyDown(KEY_RIGHT_SUPER); +#endif + return down; +} static const char *gui_dropout_reason_status(gui_dropout_reason_t reason) { switch (reason) { case GUI_DROPOUT_MISSED_FRAME: @@ -453,6 +442,7 @@ int main(int argc, char **argv) { // Load persistent settings (includes desktop path defaults) gui_settings_load(&app.settings); + gui_ui_set_scale_percent(app.settings.ui_scale_percent); // Capture limit should not persist across relaunches. app.settings.capture_limit_seconds = 0; @@ -481,7 +471,8 @@ int main(int argc, char **argv) { // Load embedded Inter font directly from memory (Apache 2.0 licensed) // Font data is ~342KB and embedded as a C array for complete portability - fonts[0] = LoadFontFromMemory(".ttf", inter_font_data, inter_font_data_size, 32, NULL, 256); + fonts[0] = LoadFontFromMemory(".ttf", inter_font_data, inter_font_data_size, + UI_FONT_ATLAS_SIZE, NULL, 256); if (fonts[0].texture.id == 0) { fprintf(stderr, "Error: Failed to load embedded Inter font data\n"); CloseWindow(); @@ -491,7 +482,8 @@ int main(int argc, char **argv) { // Load embedded Space Mono font directly from memory (SIL Open Font License) // Font data is embedded as a C array for complete portability - fonts[1] = LoadFontFromMemory(".ttf", space_mono_font_data, space_mono_font_data_size, 32, NULL, 256); + fonts[1] = LoadFontFromMemory(".ttf", space_mono_font_data, space_mono_font_data_size, + UI_FONT_ATLAS_SIZE, NULL, 256); if (fonts[1].texture.id == 0) { fprintf(stderr, "Error: Failed to load embedded Space Mono font data\n"); CloseWindow(); @@ -509,7 +501,9 @@ int main(int argc, char **argv) { } Clay_Arena clay_arena = Clay_CreateArenaWithCapacityAndMemory(clay_memory_size, clay_memory); - Clay_Initialize(clay_arena, (Clay_Dimensions){ (float)gui_layout_width(), (float)gui_layout_height() }, + Clay_Initialize(clay_arena, + (Clay_Dimensions){ (float)gui_ui_get_layout_width(), + (float)gui_ui_get_layout_height() }, (Clay_ErrorHandler){ .errorHandlerFunction = clay_error_handler, .userData = NULL, @@ -590,6 +584,9 @@ int main(int argc, char **argv) { int last_layout_width = -1; int last_layout_height = -1; bool recording_fps_throttle = false; + gui_ui_zoom_state_t ui_zoom_state = {0}; + bool ui_scale_save_pending = false; + double ui_scale_save_deadline = 0.0; #if defined(__APPLE__) && (defined(__arm64__) || defined(__aarch64__)) double last_thread_promotion_time = 0.0; const double thread_promotion_interval_s = 0.25; @@ -608,6 +605,58 @@ int main(int argc, char **argv) { recording_fps_throttle = false; } float dt = GetFrameTime(); + Vector2 wheel_delta = GetMouseWheelMoveV(); + bool primary_modifier_down = gui_primary_modifier_down(); + gui_ui_zoom_result_t ui_zoom_result = + gui_ui_zoom_process(&ui_zoom_state, + app.settings.ui_scale_percent, + primary_modifier_down, + wheel_delta.x, + wheel_delta.y); + + bool zoom_reset_pressed = primary_modifier_down && + (IsKeyPressed(KEY_ZERO) || IsKeyPressed(KEY_KP_0)); + bool zoom_in_pressed = primary_modifier_down && + (IsKeyPressed(KEY_EQUAL) || IsKeyPressed(KEY_KP_ADD)); + bool zoom_out_pressed = primary_modifier_down && + (IsKeyPressed(KEY_MINUS) || IsKeyPressed(KEY_KP_SUBTRACT)); + bool keyboard_step_pressed = zoom_in_pressed != zoom_out_pressed; + bool keyboard_zoom_pressed = zoom_reset_pressed || keyboard_step_pressed; + bool show_ui_scale_hud = + ui_zoom_result.step_attempted || keyboard_zoom_pressed; + + if (keyboard_zoom_pressed) { + ui_zoom_state.wheel_remainder = 0.0f; + if (zoom_reset_pressed) { + ui_zoom_result.percent = GUI_UI_SCALE_DEFAULT_PERCENT; + } else { + int direction = zoom_in_pressed ? 1 : -1; + ui_zoom_result.percent = + gui_ui_scale_step_percent(app.settings.ui_scale_percent, + direction); + } + } + + ui_zoom_result.changed = + ui_zoom_result.percent != app.settings.ui_scale_percent; + + if (ui_zoom_result.changed) { + app.settings.ui_scale_percent = ui_zoom_result.percent; + gui_ui_set_scale_percent(ui_zoom_result.percent); + last_layout_width = -1; + last_layout_height = -1; + ui_scale_save_pending = true; + ui_scale_save_deadline = GetTime() + 0.4; + } + + if (show_ui_scale_hud) { + gui_ui_show_scale_hud(ui_zoom_result.percent); + } + + if (ui_scale_save_pending && GetTime() >= ui_scale_save_deadline) { + gui_settings_save(&app.settings); + ui_scale_save_pending = false; + } #if defined(__APPLE__) && (defined(__arm64__) || defined(__aarch64__)) if (app.is_capturing) { double now = GetTime(); @@ -621,8 +670,8 @@ int main(int argc, char **argv) { last_thread_promotion_time = 0.0; } #endif - int current_layout_width = gui_layout_width(); - int current_layout_height = gui_layout_height(); + int current_layout_width = gui_ui_get_layout_width(); + int current_layout_height = gui_ui_get_layout_height(); if (current_layout_width != last_layout_width || current_layout_height != last_layout_height) { Clay_SetLayoutDimensions((Clay_Dimensions){ (float)current_layout_width, (float)current_layout_height @@ -671,12 +720,12 @@ int main(int argc, char **argv) { } // Update Clay mouse state - Vector2 mouse_pos = GetMousePosition(); + Vector2 mouse_pos = gui_ui_get_mouse_position(); Clay_SetPointerState((Clay_Vector2){ mouse_pos.x, mouse_pos.y }, IsMouseButtonDown(MOUSE_LEFT_BUTTON)); Clay_UpdateScrollContainers(true, (Clay_Vector2){ - GetMouseWheelMoveV().x * 20.0f, - GetMouseWheelMoveV().y * 20.0f + ui_zoom_result.passthrough_x * 20.0f, + ui_zoom_result.passthrough_y * 20.0f }, dt); // stop-on-dropout requests are posted from capture callbacks and consumed here. @@ -885,7 +934,10 @@ int main(int argc, char **argv) { } // Handle panel scroll events (e.g., waveform/FFT zoom) - float wheel = GetMouseWheelMove(); + float wheel = fabsf(ui_zoom_result.passthrough_x) > + fabsf(ui_zoom_result.passthrough_y) + ? ui_zoom_result.passthrough_x + : ui_zoom_result.passthrough_y; if (wheel != 0.0f) { panel_handle_all_scrolls(&app, wheel); } diff --git a/misrc_tools/misrc_gui/signal/gui_cvbs.c b/misrc_tools/misrc_gui/signal/gui_cvbs.c index 9299858..dfdcd32 100644 --- a/misrc_tools/misrc_gui/signal/gui_cvbs.c +++ b/misrc_tools/misrc_gui/signal/gui_cvbs.c @@ -9,6 +9,7 @@ #include "gui_trigger.h" #include "gui_vhs_fm.h" #include "../visualization/gui_text.h" +#include "../ui/gui_ui.h" #include "../../common/threading.h" #include #include @@ -2118,7 +2119,7 @@ static void render_cvbs_system_overlay(cvbs_decoder_t *decoder, decoder->overlay.system_options_rect[i] = opt_rect; bool is_selected = (sys == sys_values[i]); - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); bool hover = CheckCollisionPointRec(mouse, opt_rect); Color opt_bg = gui_dropdown_option_color(is_selected, hover); @@ -2153,7 +2154,7 @@ static void render_cvbs_system_overlay(cvbs_decoder_t *decoder, decoder->overlay.tape_options_rect[i] = opt_rect; bool is_selected = (decoder->tape_format == values[i]); - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); bool hover = CheckCollisionPointRec(mouse, opt_rect); Color opt_bg = gui_dropdown_option_color(is_selected, hover); @@ -2181,7 +2182,7 @@ static void render_cvbs_system_overlay(cvbs_decoder_t *decoder, decoder->overlay.tape_format_options_rect[i] = opt_rect; bool is_selected = (tape_fmt == i); - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); bool hover = CheckCollisionPointRec(mouse, opt_rect); Color opt_bg = gui_dropdown_option_color(is_selected, hover); diff --git a/misrc_tools/misrc_gui/signal/gui_demod.c b/misrc_tools/misrc_gui/signal/gui_demod.c index 67e532c..988ba09 100644 --- a/misrc_tools/misrc_gui/signal/gui_demod.c +++ b/misrc_tools/misrc_gui/signal/gui_demod.c @@ -708,7 +708,7 @@ static void demod_render_overlay(demod_state_t *s, gui_app_t *app, Rectangle bou Rectangle opt_rect = {mode_btn_x, opt_y + i * opt_h, mode_btn_w, opt_h}; s->overlay.mode_options_rect[i] = opt_rect; bool is_sel = (i == (int)s->mode); - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); bool hover = CheckCollisionPointRec(mouse, opt_rect); Color opt_bg = gui_dropdown_option_color(is_sel, hover); DrawRectangleRec(opt_rect, opt_bg); @@ -727,7 +727,7 @@ static void demod_render_overlay(demod_state_t *s, gui_app_t *app, Rectangle bou Rectangle opt_rect = {bw_btn_x, opt_y + i * opt_h, bw_btn_w, opt_h}; s->overlay.bw_options_rect[i] = opt_rect; bool is_sel = (i == bw_cur); - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); bool hover = CheckCollisionPointRec(mouse, opt_rect); Color opt_bg = gui_dropdown_option_color(is_sel, hover); DrawRectangleRec(opt_rect, opt_bg); @@ -747,7 +747,7 @@ static void demod_render_overlay(demod_state_t *s, gui_app_t *app, Rectangle bou Rectangle opt_rect = {out_btn_x, opt_y + i * opt_h, out_btn_w, opt_h}; s->overlay.out_options_rect[i] = opt_rect; bool is_sel = (i == out_val); - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); bool hover = CheckCollisionPointRec(mouse, opt_rect); Color opt_bg = gui_dropdown_option_color(is_sel, hover); DrawRectangleRec(opt_rect, opt_bg); diff --git a/misrc_tools/misrc_gui/ui/clay_renderer_raylib.c b/misrc_tools/misrc_gui/ui/clay_renderer_raylib.c index c767118..108ac83 100644 --- a/misrc_tools/misrc_gui/ui/clay_renderer_raylib.c +++ b/misrc_tools/misrc_gui/ui/clay_renderer_raylib.c @@ -5,6 +5,7 @@ #include #include "raylib.h" +#include "rlgl.h" #include "../visualization/gui_custom_elements.h" #include "../visualization/gui_panel.h" #include "../visualization/gui_vu_meter.h" @@ -83,6 +84,11 @@ void Clay_Raylib_Close() void Clay_Raylib_Render(Clay_RenderCommandArray renderCommands, Font* fonts) { + float ui_scale = gui_ui_get_scale_factor(); + Matrix outer_modelview = rlGetMatrixModelview(); + rlDrawRenderBatchActive(); + rlScalef(ui_scale, ui_scale, 1.0f); + for (int j = 0; j < renderCommands.length; j++) { Clay_RenderCommand *renderCommand = Clay_RenderCommandArray_Get(&renderCommands, j); @@ -125,7 +131,12 @@ void Clay_Raylib_Render(Clay_RenderCommandArray renderCommands, Font* fonts) break; } case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: { - BeginScissorMode((int)roundf(boundingBox.x), (int)roundf(boundingBox.y), (int)roundf(boundingBox.width), (int)roundf(boundingBox.height)); + Clay_BoundingBox box = renderCommand->boundingBox; + int left = (int)floorf(box.x * ui_scale); + int top = (int)floorf(box.y * ui_scale); + int right = (int)ceilf((box.x + box.width) * ui_scale); + int bottom = (int)ceilf((box.y + box.height) * ui_scale); + BeginScissorMode(left, top, right - left, bottom - top); break; } case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: { @@ -367,4 +378,7 @@ void Clay_Raylib_Render(Clay_RenderCommandArray renderCommands, Font* fonts) } } } + + rlDrawRenderBatchActive(); + rlSetMatrixModelview(outer_modelview); } diff --git a/misrc_tools/misrc_gui/ui/gui_dropdown.c b/misrc_tools/misrc_gui/ui/gui_dropdown.c index 29d662c..441f125 100644 --- a/misrc_tools/misrc_gui/ui/gui_dropdown.c +++ b/misrc_tools/misrc_gui/ui/gui_dropdown.c @@ -242,7 +242,7 @@ static bool handle_right_view_dropdown(gui_app_t *app, int ch) { bool gui_dropdown_handle_click(gui_app_t *app) { bool dropdown_clicked = false; - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); // Device dropdown (global) if (handle_device_dropdown(app)) { diff --git a/misrc_tools/misrc_gui/ui/gui_popup.c b/misrc_tools/misrc_gui/ui/gui_popup.c index 24aeec8..19ced18 100644 --- a/misrc_tools/misrc_gui/ui/gui_popup.c +++ b/misrc_tools/misrc_gui/ui/gui_popup.c @@ -118,6 +118,13 @@ void gui_popup_render(void) return; } + int popup_max_width = + gui_ui_modal_max_extent(gui_ui_get_layout_width(), 548); + int popup_max_height = + gui_ui_modal_max_extent(gui_ui_get_layout_height(), 600); + int popup_min_width = popup_max_width < 320 ? popup_max_width : 320; + int popup_min_height = popup_max_height < 156 ? popup_max_height : 156; + // Full-screen dimming overlay CLAY(CLAY_ID("PopupOverlay"), { .layout = { @@ -133,7 +140,12 @@ void gui_popup_render(void) // Dialog box CLAY(CLAY_ID("PopupDialog"), { .layout = { - .sizing = { CLAY_SIZING_FIT(.min = 320), CLAY_SIZING_FIT(0) }, + .sizing = { + CLAY_SIZING_FIT(.min = popup_min_width, + .max = popup_max_width), + CLAY_SIZING_FIT(.min = popup_min_height, + .max = popup_max_height) + }, .padding = { 24, 24, 20, 20 }, .childGap = 16, .layoutDirection = CLAY_TOP_TO_BOTTOM @@ -145,31 +157,47 @@ void gui_popup_render(void) .color = to_clay_color(COLOR_POPUP_BORDER) } }) { - // Title - CLAY(CLAY_ID("PopupTitle"), { + // Keep the decision buttons fixed and let the title/message share + // the only scroll container. This keeps Overwrite/Cancel reachable + // even at the 320x180 logical minimum used by 200% zoom. + CLAY(CLAY_ID("PopupContentScroll"), { .layout = { - .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) } + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_GROW(0) }, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + .childGap = 16 + }, + .clip = { + .horizontal = true, + .vertical = true, + .childOffset = Clay_GetScrollOffset() } }) { - CLAY_TEXT(make_string(s_popup.title), - CLAY_TEXT_CONFIG({ - .fontSize = FONT_SIZE_HEADING, - .textColor = to_clay_color(COLOR_POPUP_TITLE) - })); - } + // Title + CLAY(CLAY_ID("PopupTitle"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIT(0) } + } + }) { + CLAY_TEXT(make_string(s_popup.title), + CLAY_TEXT_CONFIG({ + .fontSize = FONT_SIZE_HEADING, + .textColor = to_clay_color(COLOR_POPUP_TITLE) + })); + } - // Message - CLAY(CLAY_ID("PopupMessage"), { - .layout = { - .sizing = { CLAY_SIZING_FIT(.max = 500), CLAY_SIZING_FIT(0) }, - .padding = { 0, 0, 8, 8 } + // Message + CLAY(CLAY_ID("PopupMessage"), { + .layout = { + .sizing = { CLAY_SIZING_FIT(.max = 500), CLAY_SIZING_FIT(0) }, + .padding = { 0, 0, 8, 8 } + } + }) { + CLAY_TEXT(make_string(s_popup.message), + CLAY_TEXT_CONFIG({ + .fontSize = FONT_SIZE_NORMAL, + .textColor = to_clay_color(COLOR_POPUP_TEXT) + })); } - }) { - CLAY_TEXT(make_string(s_popup.message), - CLAY_TEXT_CONFIG({ - .fontSize = FONT_SIZE_NORMAL, - .textColor = to_clay_color(COLOR_POPUP_TEXT) - })); } // Button row diff --git a/misrc_tools/misrc_gui/ui/gui_ui.c b/misrc_tools/misrc_gui/ui/gui_ui.c index ddda9b9..585dd04 100644 --- a/misrc_tools/misrc_gui/ui/gui_ui.c +++ b/misrc_tools/misrc_gui/ui/gui_ui.c @@ -48,6 +48,10 @@ extern const char *android_get_storage_path(void); // Track if UI consumed the current frame's click (prevents click-through) static bool s_ui_consumed_click = false; +static int s_ui_scale_percent = GUI_UI_SCALE_DEFAULT_PERCENT; +static double s_ui_scale_hud_visible_until_s = 0.0; +static char s_ui_scale_hud_title[32] = "UI Scale 100%"; +static bool s_toolbar_uses_two_rows = false; // Authoritative capture mode selected by user via CaptureModeToggle. // Keeping this outside gui_app_t protects mode from unrelated runtime mutations. static bool s_capture_mode_state_initialized = false; @@ -73,6 +77,88 @@ static bool s_cxadc_dc_anchor_valid[2] = { false, false }; static int s_cxadc_dc_anchor_raw[2] = { 0, 0 }; static int s_cxadc_dc_relative[2] = { 0, 0 }; +void gui_ui_set_scale_percent(int percent) +{ + s_ui_scale_percent = gui_ui_scale_sanitize_percent(percent); +} + +float gui_ui_get_scale_factor(void) +{ + return (float)s_ui_scale_percent / 100.0f; +} + +void gui_ui_show_scale_hud(int percent) +{ + int sanitized_percent = gui_ui_scale_sanitize_percent(percent); + snprintf(s_ui_scale_hud_title, + sizeof(s_ui_scale_hud_title), + "UI Scale %d%%", + sanitized_percent); + s_ui_scale_hud_visible_until_s = GetTime() + GUI_UI_SCALE_HUD_DURATION_S; +} + +static int gui_ui_get_base_layout_width(void) +{ +#if defined(__APPLE__) + int width = GetScreenWidth(); +#else + int width = GetRenderWidth(); + if (width <= 0) width = GetScreenWidth(); +#endif + return (width > 0) ? width : 1; +} + +static int gui_ui_get_base_layout_height(void) +{ +#if defined(__APPLE__) + int height = GetScreenHeight(); +#else + int height = GetRenderHeight(); + if (height <= 0) height = GetScreenHeight(); +#endif + return (height > 0) ? height : 1; +} + +int gui_ui_get_layout_width(void) +{ + int width = (int)ceilf((float)gui_ui_get_base_layout_width() / + gui_ui_get_scale_factor()); + return (width > 0) ? width : 1; +} + +int gui_ui_get_layout_height(void) +{ + int height = (int)ceilf((float)gui_ui_get_base_layout_height() / + gui_ui_get_scale_factor()); + return (height > 0) ? height : 1; +} + +Vector2 gui_ui_get_render_scale(void) +{ + float app_scale = gui_ui_get_scale_factor(); + Vector2 render_scale = { app_scale, app_scale }; + int layout_width = gui_ui_get_layout_width(); + int layout_height = gui_ui_get_layout_height(); + int render_width = GetRenderWidth(); + int render_height = GetRenderHeight(); + if (render_width > 0 && layout_width > 0) { + render_scale.x = (float)render_width / (float)layout_width; + } + if (render_height > 0 && layout_height > 0) { + render_scale.y = (float)render_height / (float)layout_height; + } + return render_scale; +} + +Vector2 gui_ui_get_mouse_position(void) +{ + Vector2 position = GetMousePosition(); + float scale = gui_ui_get_scale_factor(); + position.x /= scale; + position.y /= scale; + return position; +} + static const char *gui_ui_capture_mode_name(bool misrc_mode) { return misrc_mode ? "MISRC" : "HSDAOH"; } @@ -895,7 +981,7 @@ static void record_limit_set_cursor_from_field_click(gui_app_t *app) return; } - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); float content_left = field.boundingBox.x + (float)RECORD_LIMIT_TIMECODE_BORDER_X; float content_width = field.boundingBox.width - (float)(RECORD_LIMIT_TIMECODE_BORDER_X * 2); if (content_width < 8.0f) content_width = 8.0f; @@ -1304,6 +1390,23 @@ static void format_status_free_space_label(char *dst, size_t dst_len, uint64_t f } } +// Keep growing status counters inside a predictable four-character budget. +// The detailed panels retain exact values; the footer only needs a compact +// at-a-glance magnitude once a count reaches four digits. +static void format_status_counter(char *dst, size_t dst_len, uint32_t value) +{ + if (!dst || dst_len == 0) return; + if (value >= 1000000000U) { + snprintf(dst, dst_len, "%uG", value / 1000000000U); + } else if (value >= 1000000U) { + snprintf(dst, dst_len, "%uM", value / 1000000U); + } else if (value >= 1000U) { + snprintf(dst, dst_len, "%uK", value / 1000U); + } else { + snprintf(dst, dst_len, "%u", value); + } +} + static uint64_t gui_ui_recording_output_total_bytes(const gui_app_t *app) { if (!app) return 0; @@ -1437,6 +1540,7 @@ static int gui_ui_clamp_int(int value, int min_value, int max_value) if (value > max_value) return max_value; return value; } + static int gui_ui_measure_button_width(const gui_app_t *app, const char *text, int font_size, @@ -1458,6 +1562,42 @@ static int gui_ui_measure_button_width(const gui_app_t *app, return gui_ui_clamp_int(measured_width, min_width, max_width); } +static void gui_ui_ellipsize_text(const gui_app_t *app, + char *text, + size_t text_capacity, + int font_size, + int max_text_width) +{ + if (!text || text_capacity == 0 || text[0] == '\0') return; + Font font = GetFontDefault(); + if (app && app->fonts && app->fonts[0].texture.id != 0) { + font = app->fonts[0]; + } + if (!font.glyphs) { + font = GetFontDefault(); + } + if (MeasureTextEx(font, text, (float)font_size, 0.0f).x <= + (float)max_text_width) { + return; + } + + size_t cut = strlen(text); + while (cut > 0) { + cut--; + while (cut > 0 && (((unsigned char)text[cut] & 0xC0U) == 0x80U)) { + cut--; + } + text[cut] = '\0'; + strncat(text, "...", text_capacity - strlen(text) - 1); + if (MeasureTextEx(font, text, (float)font_size, 0.0f).x <= + (float)max_text_width) { + return; + } + text[cut] = '\0'; + } + snprintf(text, text_capacity, "..."); +} + static const char *rf_bits_label(uint8_t bits) { switch (bits) { case 8: return "8"; @@ -1840,7 +1980,7 @@ static int gui_ui_text_cursor_from_click(gui_app_t *app, Clay_ElementData element_data = Clay_GetElementData(element_id); if (!element_data.found || len == 0) return (int)len; - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); float content_left = element_data.boundingBox.x + left_padding; float content_width = element_data.boundingBox.width - (left_padding + right_padding); if (content_width < 1.0f) return (int)len; @@ -2312,6 +2452,10 @@ static CustomLayoutElement s_metadata_icon_element; // Render settings panel (floating modal) static void render_settings_panel(gui_app_t *app) { if (!app->settings_panel_open) return; + int settings_max_width = gui_ui_modal_max_extent(gui_ui_get_layout_width(), 1080); + int settings_max_height = gui_ui_modal_max_extent(gui_ui_get_layout_height(), 780); + int settings_min_width = gui_ui_clamp_int(settings_max_width, 1, 620); + int settings_min_height = gui_ui_clamp_int(settings_max_height, 1, 420); bool settings_cxadc_has_channel_b = false; bool settings_cxadc_mode = gui_ui_selected_device_is_cxadc(app, &settings_cxadc_has_channel_b); #ifdef ENABLE_DDD @@ -2346,7 +2490,10 @@ static void render_settings_panel(gui_app_t *app) { // Panel CLAY(CLAY_ID("SettingsPanel"), { .layout = { - .sizing = { CLAY_SIZING_FIT(.min = 620, .max = 1080), CLAY_SIZING_FIT(.min = 420, .max = 780) }, + .sizing = { + CLAY_SIZING_FIT(.min = settings_min_width, .max = settings_max_width), + CLAY_SIZING_FIT(.min = settings_min_height, .max = settings_max_height) + }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .padding = { 16, 16, 16, 16 }, .childGap = 12 @@ -2500,7 +2647,7 @@ CLAY(CLAY_ID("SettingsOutputPath"), { }, .clip = { .vertical = true, - .horizontal = false, + .horizontal = true, .childOffset = Clay_GetScrollOffset() } }) { @@ -2927,6 +3074,11 @@ static void render_record_limit_window(gui_app_t *app) { if (!s_record_limit_window_open) return; + int record_limit_max_width = gui_ui_modal_max_extent(gui_ui_get_layout_width(), 420); + int record_limit_max_height = gui_ui_modal_max_extent(gui_ui_get_layout_height(), 440); + int record_limit_min_width = gui_ui_clamp_int(record_limit_max_width, 1, 420); + int record_limit_min_height = gui_ui_clamp_int(record_limit_max_height, 1, 235); + uint32_t parsed_seconds = 0; bool timecode_valid = parse_record_limit_timecode(s_record_limit_timecode, &parsed_seconds); bool timecode_usable = timecode_valid && parsed_seconds > 0; @@ -2965,7 +3117,10 @@ static void render_record_limit_window(gui_app_t *app) CLAY(CLAY_ID("RecordLimitWindow"), { .layout = { - .sizing = { CLAY_SIZING_FIT(.min = 420, .max = 420), CLAY_SIZING_FIT(.min = 235, .max = 440) }, + .sizing = { + CLAY_SIZING_FIT(.min = record_limit_min_width, .max = record_limit_max_width), + CLAY_SIZING_FIT(.min = record_limit_min_height, .max = record_limit_max_height) + }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .padding = { 16, 16, 16, 16 }, .childGap = 12 @@ -2974,6 +3129,11 @@ static void render_record_limit_window(gui_app_t *app) .attachTo = CLAY_ATTACH_TO_ROOT, .attachPoints = { .element = CLAY_ATTACH_POINT_CENTER_CENTER, .parent = CLAY_ATTACH_POINT_CENTER_CENTER } }, + .clip = { + .horizontal = true, + .vertical = true, + .childOffset = Clay_GetScrollOffset() + }, .backgroundColor = to_clay_color(COLOR_PANEL_BG), .cornerRadius = CLAY_CORNER_RADIUS(8) }) { @@ -3247,6 +3407,10 @@ static void render_version_info_window(gui_app_t *app) { if (!s_version_info_window_open) return; + int version_max_width = gui_ui_modal_max_extent(gui_ui_get_layout_width(), 460); + int version_min_width = gui_ui_clamp_int(version_max_width, 1, 380); + int version_max_height = gui_ui_modal_max_extent(gui_ui_get_layout_height(), 780); + static char vi_version[64]; static char vi_state[24]; static char vi_device[96]; @@ -3329,7 +3493,10 @@ static void render_version_info_window(gui_app_t *app) CLAY(CLAY_ID("VersionInfoWindow"), { .layout = { - .sizing = { CLAY_SIZING_FIT(.min = 380, .max = 460), CLAY_SIZING_FIT(0) }, + .sizing = { + CLAY_SIZING_FIT(.min = version_min_width, .max = version_max_width), + CLAY_SIZING_FIT(.max = version_max_height) + }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .padding = { 16, 16, 16, 16 }, .childGap = 10 @@ -3338,6 +3505,11 @@ static void render_version_info_window(gui_app_t *app) .attachTo = CLAY_ATTACH_TO_ROOT, .attachPoints = { .element = CLAY_ATTACH_POINT_CENTER_CENTER, .parent = CLAY_ATTACH_POINT_CENTER_CENTER } }, + .clip = { + .horizontal = true, + .vertical = true, + .childOffset = Clay_GetScrollOffset() + }, .backgroundColor = to_clay_color(COLOR_PANEL_BG), .cornerRadius = CLAY_CORNER_RADIUS(8) }) { @@ -3588,6 +3760,10 @@ static void render_metadata_window(gui_app_t *app) { if (!s_metadata_window_open) return; + int metadata_max_width = gui_ui_modal_max_extent(gui_ui_get_layout_width(), 840); + int metadata_min_width = gui_ui_clamp_int(metadata_max_width, 1, 640); + int metadata_max_height = gui_ui_modal_max_extent(gui_ui_get_layout_height(), 780); + CLAY(CLAY_ID("MetadataBackdrop"), { .layout = { .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_GROW(0) } @@ -3601,7 +3777,10 @@ static void render_metadata_window(gui_app_t *app) CLAY(CLAY_ID("MetadataWindow"), { .layout = { - .sizing = { CLAY_SIZING_FIT(.min = 640, .max = 840), CLAY_SIZING_FIT(0) }, + .sizing = { + CLAY_SIZING_FIT(.min = metadata_min_width, .max = metadata_max_width), + CLAY_SIZING_FIT(.max = metadata_max_height) + }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .padding = { 16, 16, 16, 16 }, .childGap = 10 @@ -3610,6 +3789,11 @@ static void render_metadata_window(gui_app_t *app) .attachTo = CLAY_ATTACH_TO_ROOT, .attachPoints = { .element = CLAY_ATTACH_POINT_CENTER_CENTER, .parent = CLAY_ATTACH_POINT_CENTER_CENTER } }, + .clip = { + .horizontal = true, + .vertical = true, + .childOffset = Clay_GetScrollOffset() + }, .backgroundColor = to_clay_color(COLOR_PANEL_BG), .cornerRadius = CLAY_CORNER_RADIUS(8) }) { @@ -3922,6 +4106,184 @@ static void render_metadata_window(gui_app_t *app) } } +static void render_toolbar_audio_group(gui_app_t *app, + bool cxadc_mode, + bool toolbar_ultra_narrow, + bool toolbar_very_narrow, + bool show_audio_meter_labels, + int toolbar_text_size, + int toolbar_gap, + int audio_mon_width, + int audio_ch_width, + int audio_bars_panel_width, + int audio_meter_col_width, + int audio_meter_width, + int audio_meter_height, + int audio_meter_gap) +{ + CLAY(CLAY_ID("ToolbarAudioGroup"), { + .layout = { + .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIXED(32) }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .childGap = toolbar_gap + } + }) { + Color mon_bg = app->settings.audio_monitor_playback ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON; + const char *audio_mon_label = toolbar_very_narrow ? "Mon" : "Audio Mon"; + CLAY(CLAY_ID("AudioPlaybackToggle"), { + .layout = { .sizing = { CLAY_SIZING_FIXED(audio_mon_width), CLAY_SIZING_FIXED(32) }, .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } }, + .backgroundColor = to_clay_color(mon_bg), + .cornerRadius = CLAY_CORNER_RADIUS(4) + }) { + CLAY_TEXT(make_string(audio_mon_label), + CLAY_TEXT_CONFIG({ .fontSize = toolbar_text_size, .textColor = to_clay_color(COLOR_TEXT) })); + } + + Color ch_bg = app->settings.audio_monitor_ch34 ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON; +#if defined(_WIN32) + bool cxadc_win_audio_map = cxadc_mode; +#else + (void)cxadc_mode; + bool cxadc_win_audio_map = false; +#endif + const char *audio_ch_toggle_label = NULL; + if (cxadc_win_audio_map) { + audio_ch_toggle_label = app->settings.audio_monitor_ch34 + ? (toolbar_ultra_narrow ? "HSW" : "HSW CH3") + : (toolbar_ultra_narrow ? "A1/2" : "AUD 1/2"); + } else { + audio_ch_toggle_label = app->settings.audio_monitor_ch34 + ? (toolbar_ultra_narrow ? "3/4" : "CH3/4") + : (toolbar_ultra_narrow ? "1/2" : "CH1/2"); + } + CLAY(CLAY_ID("AudioChannelToggle"), { + .layout = { .sizing = { CLAY_SIZING_FIXED(audio_ch_width), CLAY_SIZING_FIXED(32) }, .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } }, + .backgroundColor = to_clay_color(ch_bg), + .cornerRadius = CLAY_CORNER_RADIUS(4) + }) { + CLAY_TEXT(make_string(audio_ch_toggle_label), + CLAY_TEXT_CONFIG({ .fontSize = toolbar_text_size, .textColor = to_clay_color(COLOR_TEXT) })); + } + + CLAY(CLAY_ID("AudioLevelBars"), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(audio_bars_panel_width), CLAY_SIZING_FIXED(32) }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + .childGap = audio_meter_gap, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .padding = { 4, 4, 4, 4 } + }, + .backgroundColor = to_clay_color((Color){25,25,30,255}), + .cornerRadius = CLAY_CORNER_RADIUS(4) + }) { + for (int i = 0; i < 4; i++) { + uint32_t p = atomic_load(&app->audio_peak[i]); + float frac = (p > 0) ? (float)p / 8388607.0f : 0.0f; + if (frac > 1.0f) frac = 1.0f; + int fill_w = (int)(frac * (float)audio_meter_width); + if (fill_w < 0) fill_w = 0; + if (fill_w > audio_meter_width) fill_w = audio_meter_width; + + Color bar_col = (frac > 0.95f) ? COLOR_CLIP_RED : (frac > 0.75f) ? COLOR_METER_YELLOW : COLOR_SYNC_GREEN; + + CLAY(CLAY_IDI("AudioMeterCol", i), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(audio_meter_col_width), CLAY_SIZING_FIXED(24) }, + .layoutDirection = CLAY_TOP_TO_BOTTOM, + .childGap = show_audio_meter_labels ? 1 : 0, + .childAlignment = { .x = CLAY_ALIGN_X_CENTER } + } + }) { + if (show_audio_meter_labels) { + snprintf(audio_ch_label[i], sizeof(audio_ch_label[i]), "CH%d", i + 1); + CLAY(CLAY_IDI("AudioChLabel", i), { .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) } } }) { + CLAY_TEXT(make_string(audio_ch_label[i]), + CLAY_TEXT_CONFIG({ .fontSize = FONT_SIZE_STATS, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + } + } + + CLAY(CLAY_IDI("AudioMeter", i), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(audio_meter_width), CLAY_SIZING_FIXED(audio_meter_height) }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + .childGap = 0 + }, + .backgroundColor = to_clay_color((Color){40,40,48,255}), + .cornerRadius = CLAY_CORNER_RADIUS(2) + }) { + if (fill_w > 0) { + CLAY(CLAY_IDI("AudioMeterFill", i), { + .layout = { .sizing = { CLAY_SIZING_FIXED(fill_w), CLAY_SIZING_GROW(0) } }, + .backgroundColor = to_clay_color(bar_col), + .cornerRadius = CLAY_CORNER_RADIUS(2) + }) { } + } + } + } + } + } + } +} + +static void render_toolbar_connection_group(gui_app_t *app, + bool toolbar_tiny, + bool toolbar_very_narrow, + int toolbar_text_size, + int toolbar_gap, + int connect_button_width, + int mode_toggle_width, + Color mode_bg, + Color mode_fg, + const char *mode_label) +{ + CLAY(CLAY_ID("ToolbarConnectionGroup"), { + .layout = { + .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIXED(32) }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .childGap = toolbar_gap + } + }) { + Color connect_color = app->is_capturing ? COLOR_CLIP_RED : COLOR_SYNC_GREEN; + const char *connect_label = app->is_capturing + ? (toolbar_tiny ? "Dis" : (toolbar_very_narrow ? "Disc" : "Disconnect")) + : (toolbar_tiny ? "Con" : (toolbar_very_narrow ? "Conn" : "Connect")); + CLAY(CLAY_ID("ConnectButton"), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(connect_button_width), CLAY_SIZING_FIXED(32) }, + .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } + }, + .backgroundColor = to_clay_color(connect_color), + .cornerRadius = CLAY_CORNER_RADIUS(4) + }) { + CLAY_TEXT(make_string(connect_label), + CLAY_TEXT_CONFIG({ + .fontSize = toolbar_text_size, + .textColor = { 255, 255, 255, 255 }, + .wrapMode = CLAY_TEXT_WRAP_NONE + })); + } + + // Capture mode toggle also selects HSDAOH backend at connect time. + CLAY(CLAY_ID("CaptureModeToggle"), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(mode_toggle_width), CLAY_SIZING_FIXED(32) }, + .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } + }, + .backgroundColor = to_clay_color(mode_bg), + .cornerRadius = CLAY_CORNER_RADIUS(4) + }) { + CLAY_TEXT(make_string(mode_label), + CLAY_TEXT_CONFIG({ + .fontSize = toolbar_text_size, + .textColor = to_clay_color(mode_fg), + .wrapMode = CLAY_TEXT_WRAP_NONE + })); + } + } +} + // Render the toolbar static void render_toolbar(gui_app_t *app) { s_settings_icon_element.type = CUSTOM_LAYOUT_ELEMENT_TYPE_SETTINGS_ICON; @@ -3938,12 +4300,11 @@ static void render_toolbar(gui_app_t *app) { version_icon_state = GUI_VERSION_ICON_CAPTURING; } s_version_icon_element.customData.version_icon.state = version_icon_state; - int toolbar_width = GetScreenWidth(); + int toolbar_width = gui_ui_get_layout_width(); bool toolbar_tiny = toolbar_width < 760; bool toolbar_ultra_narrow = toolbar_width < 900; bool toolbar_very_narrow = toolbar_width < 1020; bool toolbar_narrow = toolbar_width < 1180; - bool toolbar_medium = toolbar_width < 1360; int toolbar_padding_h = toolbar_tiny ? 4 : 8; int toolbar_gap = toolbar_tiny ? 2 : (toolbar_ultra_narrow ? 4 : (toolbar_very_narrow ? 6 : 12)); int toolbar_text_size = toolbar_very_narrow ? FONT_SIZE_DROPDOWN : FONT_SIZE_NORMAL; @@ -3951,33 +4312,37 @@ static void render_toolbar(gui_app_t *app) { int toolbar_version_icon_size = toolbar_tiny ? 16 : 20; int toolbar_metadata_icon_size = toolbar_tiny ? 14 : 18; int device_dropdown_min_width = toolbar_tiny ? 100 : (toolbar_ultra_narrow ? 120 : (toolbar_very_narrow ? 138 : (toolbar_narrow ? 160 : 180))); - int device_dropdown_max_width = toolbar_tiny ? 170 : (toolbar_ultra_narrow ? 190 : (toolbar_very_narrow ? 210 : (toolbar_narrow ? 230 : 280))); + int device_dropdown_max_width = toolbar_tiny ? 150 : (toolbar_ultra_narrow ? 190 : (toolbar_very_narrow ? 210 : (toolbar_narrow ? 230 : 280))); int device_dropdown_width = 0; int connect_button_width = toolbar_tiny ? 52 : (toolbar_ultra_narrow ? 66 : (toolbar_very_narrow ? 82 : (toolbar_narrow ? 92 : 100))); int mode_toggle_min_width = toolbar_tiny ? 58 : (toolbar_ultra_narrow ? 78 : (toolbar_very_narrow ? 96 : 112)); - int mode_toggle_max_width = toolbar_tiny ? 118 : (toolbar_ultra_narrow ? 136 : (toolbar_very_narrow ? 156 : (toolbar_narrow ? 178 : 230))); + int mode_toggle_max_width = toolbar_tiny ? 76 : (toolbar_ultra_narrow ? 136 : (toolbar_very_narrow ? 156 : (toolbar_narrow ? 178 : 230))); int mode_toggle_width = 0; int audio_mon_width = toolbar_tiny ? 44 : (toolbar_ultra_narrow ? 56 : (toolbar_very_narrow ? 68 : 90)); int audio_ch_width = toolbar_tiny ? 46 : (toolbar_ultra_narrow ? 56 : (toolbar_very_narrow ? 64 : 70)); int record_button_width = toolbar_tiny ? 56 : (toolbar_ultra_narrow ? 68 : (toolbar_very_narrow ? 74 : 80)); int icon_button_size = toolbar_tiny ? 28 : 32; - int toolbar_center_gap = toolbar_narrow ? 4 : 0; int dropdown_padding = toolbar_very_narrow ? 6 : 10; - int audio_bars_panel_width = toolbar_tiny ? 132 : (toolbar_ultra_narrow ? 164 : (toolbar_very_narrow ? 200 : 240)); - int audio_meter_col_width = toolbar_tiny ? 30 : (toolbar_ultra_narrow ? 36 : (toolbar_very_narrow ? 44 : 54)); - int audio_meter_width = toolbar_tiny ? 26 : (toolbar_ultra_narrow ? 30 : (toolbar_very_narrow ? 38 : 50)); + // The tiny profile still shows all four meters, but compresses them enough + // for the 640px minimum window at 200% scale (320 logical pixels). + int audio_bars_panel_width = toolbar_tiny ? 82 : (toolbar_ultra_narrow ? 164 : (toolbar_very_narrow ? 200 : 240)); + int audio_meter_col_width = toolbar_tiny ? 17 : (toolbar_ultra_narrow ? 36 : (toolbar_very_narrow ? 44 : 54)); + int audio_meter_width = toolbar_tiny ? 13 : (toolbar_ultra_narrow ? 30 : (toolbar_very_narrow ? 38 : 50)); int audio_meter_height = toolbar_tiny ? 6 : 8; int audio_meter_gap = toolbar_tiny ? 2 : 4; bool show_audio_meter_labels = !toolbar_tiny; bool show_version_icon = !toolbar_ultra_narrow; bool show_metadata_icon = true; bool show_device_label = !toolbar_very_narrow; - bool show_audio_monitor_controls = true; - bool show_audio_level_bars = true; const char *device_name = app->device_count > 0 ? app->devices[app->selected_device].name : "No devices"; snprintf(device_dropdown_buf, sizeof(device_dropdown_buf), "%s", device_name); + gui_ui_ellipsize_text(app, + device_dropdown_buf, + sizeof(device_dropdown_buf), + toolbar_text_size, + device_dropdown_max_width - (dropdown_padding * 2) - 16); device_dropdown_width = gui_ui_measure_button_width(app, device_dropdown_buf, toolbar_text_size, @@ -4061,16 +4426,50 @@ static void render_toolbar(gui_app_t *app) { 16, mode_toggle_min_width, mode_toggle_max_width); + // Measure the actual current composition instead of using a blanket width + // breakpoint. This preserves the original one-row 1425px default layout + // when its labels fit, while long device/Clockgen labels wrap early enough + // to keep Record, limit, and Settings visible. + int device_label_width = show_device_label + ? gui_ui_measure_button_width(app, "Device:", toolbar_text_size, + 0, 0, 0, 200) + : 0; + int toolbar_child_count = 8 + + (show_version_icon ? 1 : 0) + + (show_metadata_icon ? 1 : 0) + + (show_device_label ? 1 : 0); + int toolbar_single_row_required_width = + (toolbar_padding_h * 2) + + (show_version_icon ? toolbar_icon_button_size : 0) + + (show_metadata_icon ? toolbar_icon_button_size : 0) + + (show_metadata_icon ? 8 : (show_version_icon ? 4 : 0)) + + device_label_width + device_dropdown_width + + connect_button_width + mode_toggle_width + toolbar_gap + + audio_mon_width + audio_ch_width + audio_bars_panel_width + + (toolbar_gap * 2) + + record_button_width + (icon_button_size * 2) + + ((toolbar_child_count - 1) * toolbar_gap) + 8; + bool toolbar_two_rows = + gui_ui_toolbar_uses_two_rows(toolbar_width, + toolbar_single_row_required_width); + s_toolbar_uses_two_rows = toolbar_two_rows; CLAY(CLAY_ID("Toolbar"), { .layout = { - .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(48) }, - .layoutDirection = CLAY_LEFT_TO_RIGHT, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(toolbar_two_rows ? 84 : 48) }, + .layoutDirection = CLAY_TOP_TO_BOTTOM, .padding = { toolbar_padding_h, toolbar_padding_h, 8, 8 }, - .childGap = toolbar_gap + .childGap = toolbar_two_rows ? 4 : 0 }, .backgroundColor = to_clay_color(COLOR_TOOLBAR_BG) }) { + CLAY(CLAY_ID("ToolbarPrimaryRow"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(32) }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .childGap = toolbar_gap + } + }) { // Version/status icon (fixed, compact-hidden in tiny layouts) if (show_version_icon) { CLAY(CLAY_ID("VersionIconButton"), { @@ -4127,156 +4526,48 @@ static void render_toolbar(gui_app_t *app) { .cornerRadius = CLAY_CORNER_RADIUS(4) }) { CLAY_TEXT(make_string(device_dropdown_buf), - CLAY_TEXT_CONFIG({ .fontSize = toolbar_text_size, .textColor = to_clay_color(COLOR_TEXT) })); - } - - // Connect/Disconnect button (next to device dropdown) - Color connect_color = app->is_capturing ? COLOR_CLIP_RED : COLOR_SYNC_GREEN; - const char *connect_label = app->is_capturing - ? (toolbar_tiny ? "Dis" : (toolbar_very_narrow ? "Disc" : "Disconnect")) - : (toolbar_tiny ? "Con" : (toolbar_very_narrow ? "Conn" : "Connect")); - CLAY(CLAY_ID("ConnectButton"), { - .layout = { - .sizing = { CLAY_SIZING_FIXED(connect_button_width), CLAY_SIZING_FIXED(32) }, - .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } - }, - .backgroundColor = to_clay_color(connect_color), - .cornerRadius = CLAY_CORNER_RADIUS(4) - }) { - CLAY_TEXT(make_string(connect_label), - CLAY_TEXT_CONFIG({ .fontSize = toolbar_text_size, .textColor = { 255, 255, 255, 255 } })); - } - // Capture mode toggle also selects HSDAOH backend at connect time: - // MISRC -> raw/parser backend, HSDAOH -> upstream backend. - // For non-hsdaoh USB backends (CXADC, FX3, DdD) the MISRC/HSDAOH A/B-swap - // concept does not apply, so the toggle shows the backend name as the - // mode label and is disabled. - CLAY(CLAY_ID("CaptureModeToggle"), { - .layout = { - .sizing = { CLAY_SIZING_FIXED(mode_toggle_width), CLAY_SIZING_FIXED(32) }, - .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } - }, - .backgroundColor = to_clay_color(mode_bg), - .cornerRadius = CLAY_CORNER_RADIUS(4) - }) { - CLAY_TEXT(make_string(mode_label), - CLAY_TEXT_CONFIG({ .fontSize = toolbar_text_size, .textColor = to_clay_color(mode_fg) })); + CLAY_TEXT_CONFIG({ + .fontSize = toolbar_text_size, + .textColor = to_clay_color(COLOR_TEXT), + .wrapMode = CLAY_TEXT_WRAP_NONE + })); + } + + if (!toolbar_two_rows) { + render_toolbar_connection_group(app, + toolbar_tiny, + toolbar_very_narrow, + toolbar_text_size, + toolbar_gap, + connect_button_width, + mode_toggle_width, + mode_bg, + mode_fg, + mode_label); } // Spacer CLAY(CLAY_ID("ToolbarSpacer2"), { .layout = { - .sizing = { - toolbar_medium ? CLAY_SIZING_FIXED(toolbar_center_gap) : CLAY_SIZING_GROW(0), - CLAY_SIZING_GROW(0) - } + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_GROW(0) } } }) {} - if (show_audio_monitor_controls) { - // Audio playback monitoring toggle - Color mon_bg = app->settings.audio_monitor_playback ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON; - const char *audio_mon_label = toolbar_very_narrow ? "Mon" : "Audio Mon"; - CLAY(CLAY_ID("AudioPlaybackToggle"), { - .layout = { .sizing = { CLAY_SIZING_FIXED(audio_mon_width), CLAY_SIZING_FIXED(32) }, .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } }, - .backgroundColor = to_clay_color(mon_bg), - .cornerRadius = CLAY_CORNER_RADIUS(4) - }) { - CLAY_TEXT(make_string(audio_mon_label), - CLAY_TEXT_CONFIG({ .fontSize = toolbar_text_size, .textColor = to_clay_color(COLOR_TEXT) })); - } - // Audio channel select (CH1/2 vs CH3/4) - Color ch_bg = app->settings.audio_monitor_ch34 ? COLOR_BUTTON_ACTIVE : COLOR_BUTTON; -#if defined(_WIN32) - bool cxadc_win_audio_map = cxadc_mode; -#else - bool cxadc_win_audio_map = false; -#endif - const char *audio_ch_toggle_label = NULL; - if (cxadc_win_audio_map) { - audio_ch_toggle_label = app->settings.audio_monitor_ch34 - ? (toolbar_ultra_narrow ? "HSW" : "HSW CH3") - : (toolbar_ultra_narrow ? "A1/2" : "AUD 1/2"); - } else { - audio_ch_toggle_label = app->settings.audio_monitor_ch34 - ? (toolbar_ultra_narrow ? "3/4" : "CH3/4") - : (toolbar_ultra_narrow ? "1/2" : "CH1/2"); - } - CLAY(CLAY_ID("AudioChannelToggle"), { - .layout = { .sizing = { CLAY_SIZING_FIXED(audio_ch_width), CLAY_SIZING_FIXED(32) }, .childAlignment = { .x = CLAY_ALIGN_X_CENTER, .y = CLAY_ALIGN_Y_CENTER } }, - .backgroundColor = to_clay_color(ch_bg), - .cornerRadius = CLAY_CORNER_RADIUS(4) - }) { - CLAY_TEXT(make_string(audio_ch_toggle_label), - CLAY_TEXT_CONFIG({ .fontSize = toolbar_text_size, .textColor = to_clay_color(COLOR_TEXT) })); - } - } - - if (show_audio_level_bars) { - // 4 channel horizontal audio meters (compact for toolbar) - CLAY(CLAY_ID("AudioLevelBars"), { - .layout = { - .sizing = { CLAY_SIZING_FIXED(audio_bars_panel_width), CLAY_SIZING_FIXED(32) }, - .layoutDirection = CLAY_LEFT_TO_RIGHT, - .childGap = audio_meter_gap, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, - .padding = { 4, 4, 4, 4 } - }, - .backgroundColor = to_clay_color((Color){25,25,30,255}), - .cornerRadius = CLAY_CORNER_RADIUS(4) - }) { - // 4 horizontal meters in a row with labels - for (int i = 0; i < 4; i++) { - uint32_t p = atomic_load(&app->audio_peak[i]); - float frac = (p > 0) ? (float)p / 8388607.0f : 0.0f; - if (frac > 1.0f) frac = 1.0f; - int fill_w = (int)(frac * (float)audio_meter_width); - if (fill_w < 0) fill_w = 0; - if (fill_w > audio_meter_width) fill_w = audio_meter_width; - - // Color thresholds - Color bar_col = (frac > 0.95f) ? COLOR_CLIP_RED : (frac > 0.75f) ? COLOR_METER_YELLOW : COLOR_SYNC_GREEN; - - // Column: channel label above meter - CLAY(CLAY_IDI("AudioMeterCol", i), { - .layout = { - .sizing = { CLAY_SIZING_FIXED(audio_meter_col_width), CLAY_SIZING_FIXED(24) }, - .layoutDirection = CLAY_TOP_TO_BOTTOM, - .childGap = show_audio_meter_labels ? 1 : 0, - .childAlignment = { .x = CLAY_ALIGN_X_CENTER } - } - }) { - // Channel label (CH1-CH4) - if (show_audio_meter_labels) { - snprintf(audio_ch_label[i], sizeof(audio_ch_label[i]), "CH%d", i + 1); - CLAY(CLAY_IDI("AudioChLabel", i), { .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) } } }) { - CLAY_TEXT(make_string(audio_ch_label[i]), - CLAY_TEXT_CONFIG({ .fontSize = FONT_SIZE_STATS, .textColor = to_clay_color(COLOR_TEXT_DIM) })); - } - } - - // Horizontal meter bar container - CLAY(CLAY_IDI("AudioMeter", i), { - .layout = { - .sizing = { CLAY_SIZING_FIXED(audio_meter_width), CLAY_SIZING_FIXED(audio_meter_height) }, - .layoutDirection = CLAY_LEFT_TO_RIGHT, - .childGap = 0 - }, - .backgroundColor = to_clay_color((Color){40,40,48,255}), - .cornerRadius = CLAY_CORNER_RADIUS(2) - }) { - // Fill bar (left side) - if (fill_w > 0) { - CLAY(CLAY_IDI("AudioMeterFill", i), { - .layout = { .sizing = { CLAY_SIZING_FIXED(fill_w), CLAY_SIZING_GROW(0) } }, - .backgroundColor = to_clay_color(bar_col), - .cornerRadius = CLAY_CORNER_RADIUS(2) - }) { } - } - } - } - } - } + if (!toolbar_two_rows) { + render_toolbar_audio_group(app, + cxadc_mode, + toolbar_ultra_narrow, + toolbar_very_narrow, + show_audio_meter_labels, + toolbar_text_size, + toolbar_gap, + audio_mon_width, + audio_ch_width, + audio_bars_panel_width, + audio_meter_col_width, + audio_meter_width, + audio_meter_height, + audio_meter_gap); } bool playback_mode = gui_ui_selected_device_is_playback(app); bool playback_running = playback_mode && gui_playback_is_running(app); @@ -4372,6 +4663,49 @@ static void render_toolbar(gui_app_t *app) { }) {} } + } + if (toolbar_two_rows) { + CLAY(CLAY_ID("ToolbarAudioRow"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(32) }, + .layoutDirection = CLAY_LEFT_TO_RIGHT, + .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .childGap = toolbar_gap + } + }) { + render_toolbar_connection_group(app, + toolbar_tiny, + toolbar_very_narrow, + toolbar_text_size, + toolbar_gap, + connect_button_width, + mode_toggle_width, + mode_bg, + mode_fg, + mode_label); + + CLAY(CLAY_ID("ToolbarSecondarySpacer"), { + .layout = { + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_GROW(0) } + } + }) {} + + render_toolbar_audio_group(app, + cxadc_mode, + toolbar_ultra_narrow, + toolbar_very_narrow, + show_audio_meter_labels, + toolbar_text_size, + toolbar_gap, + audio_mon_width, + audio_ch_width, + audio_bars_panel_width, + audio_meter_col_width, + audio_meter_width, + audio_meter_height, + audio_meter_gap); + } + } } } @@ -4388,8 +4722,8 @@ static void render_toolbar(gui_app_t *app) { // Render per-channel stats panel (trigger controls moved to waveform panel overlay) static void render_channel_stats(gui_app_t *app, int channel) { - int screen_w = GetScreenWidth(); - int screen_h = GetScreenHeight(); + int screen_w = gui_ui_get_layout_width(); + int screen_h = gui_ui_get_layout_height(); bool quarter_scale_layout = (screen_w <= 1000 && screen_h <= 700); // Get per-channel stats uint32_t clip_pos, clip_neg; @@ -4777,7 +5111,7 @@ static void render_playback_timeline_row(int channel_index, const char *timeline Color timeline_text_color = enabled ? COLOR_TEXT : COLOR_TEXT_DIM; Color timeline_track_color = enabled ? (Color){45, 45, 52, 255} : (Color){33, 33, 38, 255}; Color timeline_fill_color = enabled ? COLOR_SYNC_GREEN : COLOR_TEXT_DIM; - int screen_width = GetScreenWidth(); + int screen_width = gui_ui_get_layout_width(); int left_pad_width = screen_width < 900 ? 60 : 74; int label_width = screen_width < 900 ? 120 : 150; int right_pad_width = screen_width < 900 ? 0 : 189; @@ -4884,7 +5218,7 @@ static void render_channels_panel(gui_app_t *app) { }, .backgroundColor = to_clay_color(COLOR_PANEL_BG) }) { - int screen_width = GetScreenWidth(); + int screen_width = gui_ui_get_layout_width(); int playback_track_width_px = screen_width < 900 ? 180 : (screen_width < 1150 ? 240 : 300); int playback_fill_w_a = 0; int playback_fill_w_b = 0; @@ -4970,19 +5304,26 @@ static void render_channels_panel(gui_app_t *app) { // Render status bar static void render_status_bar(gui_app_t *app) { - int status_width = GetScreenWidth(); - int status_height = GetScreenHeight(); - bool status_quarter_scale = (status_width <= 1000 && status_height <= 700); - bool status_compact = status_width < 1040; - bool status_narrow = status_width < 900; - bool status_tiny = status_width < 760; - bool show_sync_status = !status_tiny && !status_quarter_scale; - bool show_sample_rate = !status_tiny && !status_quarter_scale; + int status_width = gui_ui_get_layout_width(); + int status_height = gui_ui_get_layout_height(); + gui_ui_status_layout_mode_t status_layout = + gui_ui_get_status_layout_mode(status_width, status_height, + app->is_recording); + bool status_quarter_scale = + status_width <= GUI_UI_STATUS_QUARTER_MAX_WIDTH && + status_height <= GUI_UI_STATUS_QUARTER_MAX_HEIGHT; + bool status_compact = status_layout != GUI_UI_STATUS_LAYOUT_FULL_SINGLE; + bool status_narrow = + !gui_ui_status_shows_extended_counters(status_width, + app->is_recording); + bool status_tiny = status_width < GUI_UI_STATUS_TINY_BREAKPOINT; + bool status_minimal = status_layout == GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE; + bool show_sync_status = !status_minimal; + bool show_sample_rate = !status_minimal; bool show_frame_count = !status_narrow; bool show_missed_count = !status_narrow; bool show_error_count = !status_narrow; - bool show_status_message = !status_tiny && !status_quarter_scale; - // Detect an error/denied/failed status message so the status bar can + // Detect an error/denied/failed or critical capture-stop status so the bar can // yield space to it: when an error is being shown, hide the free-space // readout (and widen the message budget) so the actual error text isn't // truncated behind the free-space label. This is what makes CXADC/USB @@ -4998,45 +5339,65 @@ static void render_status_bar(gui_app_t *app) { strstr(raw_status_gate, "failed") != NULL || strstr(raw_status_gate, "Failed") != NULL || strstr(raw_status_gate, "not granted") != NULL || - strstr(raw_status_gate, "timed out") != NULL; - } + strstr(raw_status_gate, "timed out") != NULL || + strstr(raw_status_gate, "Capture stopped:") != NULL; + } + // Long error messages get an explicit second line whenever there is enough + // height. Normal recording status is redundant with the timer, so omit it + // at every width to leave room for free-space/runway information. + bool status_two_rows = gui_ui_status_uses_two_rows(status_layout, + status_is_error); + bool show_status_message = status_is_error || + (!status_minimal && !app->is_recording); /* Keep free-space visible during normal startup/layout sizes; hide it when * an error/denied status is active so the error text gets the left-side * space, and on very tiny widths where preserving right-side counters * takes priority. */ - bool show_free_space = !status_tiny && !status_is_error; - int sample_rate_value_width = status_narrow ? 68 : 80; - int samples_value_width = status_tiny ? 48 : (status_narrow ? 54 : 60); - int frames_value_width = 50; - int small_counter_width = 20; + bool show_free_space = !status_tiny && !status_minimal && !status_is_error; + int sample_rate_value_width = status_compact ? 68 : 80; + int samples_value_width = status_tiny ? 48 : (status_compact ? 54 : 60); + int frames_value_width = 32; + int small_counter_width = 32; int buffer_value_width = status_tiny ? 28 : (status_narrow ? 32 : 35); - int status_bar_gap = status_tiny ? 6 : (status_compact ? 10 : 20); - int status_right_gap = status_tiny ? 8 : (status_compact ? 12 : 16); - int status_left_gap = show_status_message ? (status_tiny ? 4 : 8) : 0; + int status_bar_gap = status_tiny ? 6 : (status_compact ? 8 : 20); + int status_right_gap = status_tiny ? 8 : (status_compact ? 8 : 16); + int status_left_gap = status_tiny ? 4 : (status_compact ? 6 : 8); // Widen the message budget when free-space is hidden (error case) so the // full error reason fits instead of being ellipsised at the normal width. int status_message_max_chars = status_is_error - ? (status_narrow ? 48 : 64) - : (status_narrow ? 16 : (status_compact ? 22 : 30)); + ? (status_tiny ? 30 : (status_two_rows ? 64 : 36)) + : (status_narrow ? 16 : (status_compact ? 20 : 30)); int status_font_size = FONT_SIZE_STATUS - 1; const char *rf_buffer_label = status_compact ? "RF:" : "RF Buffer:"; - const char *audio_buffer_label = status_compact ? "Audio:" : "Audio Buffer:"; - const char *samples_label = status_tiny ? "S:" : (status_narrow ? "Samp:" : "Samples:"); + const char *audio_buffer_label = status_compact ? "Aud:" : "Audio Buffer:"; + const char *samples_label = status_tiny ? "S:" : (status_compact ? "Samp:" : "Samples:"); + const char *frames_label = status_compact ? "F:" : "Frames:"; + const char *missed_label = status_compact ? "M:" : "Missed:"; + const char *errors_label = status_compact ? "E:" : "Errors:"; + Clay_SizingAxis status_row_width = status_two_rows + ? CLAY_SIZING_GROW(0) + : CLAY_SIZING_FIT(0); + Clay_SizingAxis status_row_height = status_two_rows + ? CLAY_SIZING_FIXED(22) + : CLAY_SIZING_FIT(0); + Clay_SizingAxis status_spacer_height = status_two_rows + ? CLAY_SIZING_FIXED(0) + : CLAY_SIZING_GROW(0); update_status_free_space(app); CLAY(CLAY_ID("StatusBar"), { .layout = { - .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(28) }, - .layoutDirection = CLAY_LEFT_TO_RIGHT, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, - .padding = { 12, 12, 0, 0 }, - .childGap = status_bar_gap + .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_FIXED(status_two_rows ? 52 : 28) }, + .layoutDirection = status_two_rows ? CLAY_TOP_TO_BOTTOM : CLAY_LEFT_TO_RIGHT, + .childAlignment = { .y = status_two_rows ? CLAY_ALIGN_Y_TOP : CLAY_ALIGN_Y_CENTER }, + .padding = { 12, 12, status_two_rows ? 2 : 0, status_two_rows ? 2 : 0 }, + .childGap = status_two_rows ? 2 : status_bar_gap }, .backgroundColor = to_clay_color(COLOR_TOOLBAR_BG) }) { // Left side: status + free space/runway CLAY(CLAY_ID("StatusLeft"), { .layout = { - .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) }, + .sizing = { status_row_width, status_row_height }, .layoutDirection = CLAY_LEFT_TO_RIGHT, .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, .childGap = status_left_gap @@ -5062,7 +5423,7 @@ static void render_status_bar(gui_app_t *app) { snprintf(status_record_timer_display, sizeof(status_record_timer_display), "%02d:%02d:%02d", rec_hours, rec_mins, rec_secs); CLAY_TEXT(make_string(status_record_timer_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT), .wrapMode = CLAY_TEXT_WRAP_NONE })); } if (show_status_message) { @@ -5078,14 +5439,7 @@ static void render_status_bar(gui_app_t *app) { snprintf(status_message_display, sizeof(status_message_display), "%s", raw_status); } Color status_color = COLOR_TEXT_DIM; - if (strstr(raw_status, "denied") != NULL || - strstr(raw_status, "Denied") != NULL || - strstr(raw_status, "error") != NULL || - strstr(raw_status, "Error") != NULL || - strstr(raw_status, "failed") != NULL || - strstr(raw_status, "Failed") != NULL || - strstr(raw_status, "not granted") != NULL || - strstr(raw_status, "timed out") != NULL) { + if (status_is_error) { status_color = COLOR_CLIP_RED; } else if (strstr(raw_status, "Requesting") != NULL || strstr(raw_status, "Reconnecting") != NULL || @@ -5101,7 +5455,7 @@ static void render_status_bar(gui_app_t *app) { } }) { CLAY_TEXT(make_string(status_message_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(status_color) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(status_color), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } @@ -5155,25 +5509,31 @@ static void render_status_bar(gui_app_t *app) { } }) { CLAY_TEXT(make_string(status_free_space_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(free_space_color) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(free_space_color), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } } // Flexible spacer between left and right sections. CLAY(CLAY_ID("StatusSpacer"), { - .layout = { .sizing = { CLAY_SIZING_GROW(0), CLAY_SIZING_GROW(0) } } + .layout = { .sizing = { CLAY_SIZING_GROW(0), status_spacer_height } } }) {} - // Right side: stream/capture counters - CLAY(CLAY_ID("StatusRight"), { + // On minimal layouts a critical stop reason owns the full row; stream + // counters are lower priority and would otherwise make it unreadable. + if (!(status_is_error && status_minimal)) { + // Right side: stream/capture counters + CLAY(CLAY_ID("StatusRight"), { .layout = { - .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) }, + .sizing = { status_row_width, status_row_height }, .layoutDirection = CLAY_LEFT_TO_RIGHT, - .childAlignment = { .y = CLAY_ALIGN_Y_CENTER }, + .childAlignment = { + .x = status_two_rows ? CLAY_ALIGN_X_RIGHT : CLAY_ALIGN_X_LEFT, + .y = CLAY_ALIGN_Y_CENTER + }, .childGap = status_right_gap } - }) { + }) { if (show_sync_status) { bool synced = atomic_load(&app->stream_synced); Color sync_color = synced ? COLOR_SYNC_GREEN : COLOR_SYNC_RED; @@ -5185,9 +5545,9 @@ static void render_status_bar(gui_app_t *app) { } }) { CLAY_TEXT(CLAY_STRING("Sync:"), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); CLAY_TEXT(synced ? CLAY_STRING("OK") : CLAY_STRING("--"), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(sync_color) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(sync_color), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } // Sample rate @@ -5199,7 +5559,7 @@ static void render_status_bar(gui_app_t *app) { .layout = { .sizing = { CLAY_SIZING_FIXED(sample_rate_value_width), CLAY_SIZING_FIT(0) } } }) { CLAY_TEXT(make_string(status_sample_rate_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } } @@ -5227,18 +5587,20 @@ static void render_status_bar(gui_app_t *app) { } }) { CLAY_TEXT(make_string(samples_label), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); CLAY(CLAY_ID("SamplesValue"), { .layout = { .sizing = { CLAY_SIZING_FIXED(samples_value_width), CLAY_SIZING_FIT(0) } } }) { CLAY_TEXT(make_string(status_samples_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } if (show_frame_count) { // Frames count placed next to Samples uint32_t frames = atomic_load(&app->frame_count); - snprintf(status_frames_display, sizeof(status_frames_display), "%u", frames); + format_status_counter(status_frames_display, + sizeof(status_frames_display), + frames); CLAY(CLAY_ID("FrameStatus"), { .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) }, @@ -5246,13 +5608,13 @@ static void render_status_bar(gui_app_t *app) { .childGap = 4 } }) { - CLAY_TEXT(CLAY_STRING("Frames:"), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT(make_string(frames_label), + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); CLAY(CLAY_ID("FrameValue"), { .layout = { .sizing = { CLAY_SIZING_FIXED(frames_value_width), CLAY_SIZING_FIT(0) } } }) { CLAY_TEXT(make_string(status_frames_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(COLOR_TEXT), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } } @@ -5261,7 +5623,9 @@ static void render_status_bar(gui_app_t *app) { // Missed frames count uint32_t missed = app->is_capturing ? atomic_load(&app->missed_frame_count) : 0; if (show_missed_count) { - snprintf(status_missed_display, sizeof(status_missed_display), "%u", missed); + format_status_counter(status_missed_display, + sizeof(status_missed_display), + missed); CLAY(CLAY_ID("MissedStatus"), { .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) }, @@ -5269,13 +5633,13 @@ static void render_status_bar(gui_app_t *app) { .childGap = 4 } }) { - CLAY_TEXT(CLAY_STRING("Missed:"), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT(make_string(missed_label), + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); CLAY(CLAY_ID("MissedValue"), { .layout = { .sizing = { CLAY_SIZING_FIXED(small_counter_width), CLAY_SIZING_FIT(0) } } }) { CLAY_TEXT(make_string(status_missed_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(missed > 0 ? COLOR_CLIP_RED : COLOR_TEXT) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(missed > 0 ? COLOR_CLIP_RED : COLOR_TEXT), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } } @@ -5283,7 +5647,9 @@ static void render_status_bar(gui_app_t *app) { // Total errors (single combined counter) uint32_t errors = app->is_capturing ? atomic_load(&app->error_count) : 0; if (show_error_count) { - snprintf(status_errors_display, sizeof(status_errors_display), "%u", errors); + format_status_counter(status_errors_display, + sizeof(status_errors_display), + errors); CLAY(CLAY_ID("ErrorStatus"), { .layout = { .sizing = { CLAY_SIZING_FIT(0), CLAY_SIZING_FIT(0) }, @@ -5291,13 +5657,13 @@ static void render_status_bar(gui_app_t *app) { .childGap = 4 } }) { - CLAY_TEXT(CLAY_STRING("Errors:"), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT(make_string(errors_label), + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); CLAY(CLAY_ID("ErrorValue"), { .layout = { .sizing = { CLAY_SIZING_FIXED(small_counter_width), CLAY_SIZING_FIT(0) } } }) { CLAY_TEXT(make_string(status_errors_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(errors > 0 ? COLOR_CLIP_RED : COLOR_TEXT) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(errors > 0 ? COLOR_CLIP_RED : COLOR_TEXT), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } } @@ -5317,13 +5683,13 @@ static void render_status_bar(gui_app_t *app) { } }) { CLAY_TEXT(make_string(rf_buffer_label), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); CLAY(CLAY_ID("RFBufValue"), { .layout = { .sizing = { CLAY_SIZING_FIXED(buffer_value_width), CLAY_SIZING_FIT(0) } } }) { Color rf_color = (rf_pct > 90) ? COLOR_CLIP_RED : (rf_pct > 75) ? COLOR_METER_YELLOW : COLOR_TEXT; CLAY_TEXT(make_string(status_rf_buf_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(rf_color) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(rf_color), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } @@ -5342,19 +5708,82 @@ static void render_status_bar(gui_app_t *app) { } }) { CLAY_TEXT(make_string(audio_buffer_label), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .textColor = to_clay_color(COLOR_TEXT_DIM), .wrapMode = CLAY_TEXT_WRAP_NONE })); CLAY(CLAY_ID("AudBufValue"), { .layout = { .sizing = { CLAY_SIZING_FIXED(buffer_value_width), CLAY_SIZING_FIT(0) } } }) { Color aud_color = (aud_pct > 90) ? COLOR_CLIP_RED : (aud_pct > 75) ? COLOR_METER_YELLOW : COLOR_TEXT; CLAY_TEXT(make_string(status_aud_buf_display), - CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(aud_color) })); + CLAY_TEXT_CONFIG({ .fontSize = status_font_size, .fontId = 1, .textColor = to_clay_color(aud_color), .wrapMode = CLAY_TEXT_WRAP_NONE })); } } + } } } } +static void render_ui_scale_hud(void) +{ + double remaining_s = s_ui_scale_hud_visible_until_s - GetTime(); + float opacity = gui_ui_scale_hud_opacity(remaining_s); + if (opacity <= 0.0f) return; + + Color hud_background = COLOR_PANEL_BG; + Color hud_border = COLOR_BUTTON_ACTIVE; + Color hud_text = COLOR_TEXT; + Color hud_hint = COLOR_TEXT_DIM; + hud_background.a = (unsigned char)(232.0f * opacity); + hud_border.a = (unsigned char)(220.0f * opacity); + hud_text.a = (unsigned char)(255.0f * opacity); + hud_hint.a = (unsigned char)(255.0f * opacity); + +#if defined(__APPLE__) + const Clay_String reset_hint = CLAY_STRING("Cmd+0 to reset to 100%"); +#else + const Clay_String reset_hint = CLAY_STRING("Ctrl+0 to reset to 100%"); +#endif + + CLAY(CLAY_ID("UiScaleHud"), { + .layout = { + .sizing = { CLAY_SIZING_FIXED(224), CLAY_SIZING_FIT(0) }, + .padding = { 12, 12, 10, 10 }, + .childGap = 4, + .childAlignment = { .x = CLAY_ALIGN_X_CENTER }, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .floating = { + .offset = { .x = 0, .y = 10 }, + .zIndex = 1100, + .parentId = CLAY_ID("Toolbar").id, + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_TOP, + .parent = CLAY_ATTACH_POINT_CENTER_BOTTOM + }, + .pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH, + .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID + }, + .backgroundColor = to_clay_color(hud_background), + .cornerRadius = CLAY_CORNER_RADIUS(8), + .border = { + .width = { 1, 1, 1, 1 }, + .color = to_clay_color(hud_border) + } + }) { + CLAY_TEXT(make_string(s_ui_scale_hud_title), + CLAY_TEXT_CONFIG({ + .fontSize = 24, + .textColor = to_clay_color(hud_text), + .wrapMode = CLAY_TEXT_WRAP_NONE + })); + CLAY_TEXT(reset_hint, + CLAY_TEXT_CONFIG({ + .fontSize = 14, + .textColor = to_clay_color(hud_hint), + .wrapMode = CLAY_TEXT_WRAP_NONE + })); + } +} + // Main layout function void gui_render_layout(gui_app_t *app) { gui_ui_sync_capture_mode_state(app); @@ -5393,7 +5822,8 @@ void gui_render_layout(gui_app_t *app) { // Device dropdown overlay (if open) if (gui_dropdown_is_open(DROPDOWN_DEVICE, 0) && app->device_count > 0) { - int overlay_screen_width = GetScreenWidth(); + int overlay_screen_width = gui_ui_get_layout_width(); + bool overlay_toolbar_two_rows = s_toolbar_uses_two_rows; bool overlay_toolbar_tiny = overlay_screen_width < 760; bool overlay_toolbar_ultra_narrow = overlay_screen_width < 900; bool overlay_toolbar_very_narrow = overlay_screen_width < 1020; @@ -5429,7 +5859,8 @@ void gui_render_layout(gui_app_t *app) { .floating = { .attachTo = CLAY_ATTACH_TO_ELEMENT_WITH_ID, .parentId = CLAY_ID("DeviceDropdown").id, - .attachPoints = { .element = CLAY_ATTACH_POINT_LEFT_TOP, .parent = CLAY_ATTACH_POINT_LEFT_BOTTOM } + .attachPoints = { .element = CLAY_ATTACH_POINT_LEFT_TOP, .parent = CLAY_ATTACH_POINT_LEFT_BOTTOM }, + .offset = { .x = 0, .y = overlay_toolbar_two_rows ? 44 : 0 } }, .backgroundColor = to_clay_color(COLOR_PANEL_BG), .cornerRadius = CLAY_CORNER_RADIUS(4) @@ -5454,6 +5885,10 @@ void gui_render_layout(gui_app_t *app) { } } + // Transient, pointer-transparent zoom feedback. It is attached to the + // toolbar so its position follows both the one-row and wrapped layouts. + render_ui_scale_hud(); + // Popup overlay (renders on top of everything) gui_popup_render(); } @@ -5582,7 +6017,8 @@ void gui_handle_interactions(gui_app_t *app) { !s_record_limit_window_open && !s_version_info_window_open && !s_metadata_window_open) { - if (!gui_ui_seek_playback_from_track(app, s_playback_scrub_track_index, GetMousePosition().x)) { + if (!gui_ui_seek_playback_from_track(app, s_playback_scrub_track_index, + gui_ui_get_mouse_position().x)) { s_playback_scrub_active = false; } } @@ -6128,7 +6564,8 @@ void gui_handle_interactions(gui_app_t *app) { gui_ui_selected_device_is_playback(app) && Clay_PointerOver(CLAY_IDI("PlaybackTimelineTrack", 0))) { s_playback_scrub_track_index = 0; - if (gui_ui_seek_playback_from_track(app, s_playback_scrub_track_index, GetMousePosition().x)) { + if (gui_ui_seek_playback_from_track(app, s_playback_scrub_track_index, + gui_ui_get_mouse_position().x)) { s_playback_scrub_active = true; } else { s_playback_scrub_active = false; @@ -6141,7 +6578,8 @@ void gui_handle_interactions(gui_app_t *app) { gui_ui_selected_device_is_playback(app) && Clay_PointerOver(CLAY_IDI("PlaybackTimelineTrack", 1))) { s_playback_scrub_track_index = 1; - if (gui_ui_seek_playback_from_track(app, s_playback_scrub_track_index, GetMousePosition().x)) { + if (gui_ui_seek_playback_from_track(app, s_playback_scrub_track_index, + gui_ui_get_mouse_position().x)) { s_playback_scrub_active = true; } else { s_playback_scrub_active = false; @@ -6150,7 +6588,7 @@ void gui_handle_interactions(gui_app_t *app) { gui_ui_set_click_consumed(); return; } - Vector2 click_pos = GetMousePosition(); + Vector2 click_pos = gui_ui_get_mouse_position(); bool mode_toggle_hit = Clay_PointerOver(CLAY_ID("CaptureModeToggle")); bool mode_toggle_cxadc_clockgen = false; bool mode_toggle_is_cxadc = gui_ui_selected_device_is_cxadc(app, &mode_toggle_cxadc_clockgen); diff --git a/misrc_tools/misrc_gui/ui/gui_ui.h b/misrc_tools/misrc_gui/ui/gui_ui.h index 5f81880..ee1c897 100644 --- a/misrc_tools/misrc_gui/ui/gui_ui.h +++ b/misrc_tools/misrc_gui/ui/gui_ui.h @@ -3,6 +3,7 @@ #include "../core/gui_app.h" #include "clay.h" +#include "gui_ui_scale.h" // UI colors #define COLOR_BG (Color){ 30, 30, 35, 255 } @@ -48,6 +49,18 @@ void gui_handle_interactions(gui_app_t *app); void gui_ui_sync_capture_mode_state(gui_app_t *app); void gui_ui_sync_android_keyboard_state(void); +// Application-controlled UI zoom. Layout and pointer coordinates remain in +// logical units while the renderer scales them into the window framebuffer. +void gui_ui_set_scale_percent(int percent); +float gui_ui_get_scale_factor(void); +// Physical framebuffer pixels per logical UI unit. This includes both the +// application zoom and any OS backing scale such as macOS Retina. +Vector2 gui_ui_get_render_scale(void); +void gui_ui_show_scale_hud(int percent); +int gui_ui_get_layout_width(void); +int gui_ui_get_layout_height(void); +Vector2 gui_ui_get_mouse_position(void); + // Check if UI consumed the current frame's click (prevents click-through to oscilloscope) bool gui_ui_click_consumed(void); diff --git a/misrc_tools/misrc_gui/ui/gui_ui_scale.c b/misrc_tools/misrc_gui/ui/gui_ui_scale.c new file mode 100644 index 0000000..4510cfc --- /dev/null +++ b/misrc_tools/misrc_gui/ui/gui_ui_scale.c @@ -0,0 +1,183 @@ +#include "gui_ui_scale.h" + +#include +#include +#include +#include + +int gui_ui_scale_sanitize_percent(int percent) +{ + if (percent == GUI_UI_SCALE_MIN_PERCENT) return percent; + if (percent >= 80 && percent <= GUI_UI_SCALE_MAX_PERCENT && + (percent % GUI_UI_SCALE_STEP_PERCENT) == 0) return percent; + return GUI_UI_SCALE_DEFAULT_PERCENT; +} + +int gui_ui_scale_parse_percent(const char *text) +{ + if (!text || text[0] == '\0') return GUI_UI_SCALE_DEFAULT_PERCENT; + + errno = 0; + char *end = NULL; + long parsed = strtol(text, &end, 10); + if (errno == ERANGE || end == text || *end != '\0' || + parsed < INT_MIN || parsed > INT_MAX) { + return GUI_UI_SCALE_DEFAULT_PERCENT; + } + + return gui_ui_scale_sanitize_percent((int)parsed); +} + +int gui_ui_scale_step_percent(int current_percent, int direction) +{ + int percent = gui_ui_scale_sanitize_percent(current_percent); + if (direction == 0) return percent; + + int next_percent; + if (direction > 0 && percent == GUI_UI_SCALE_MIN_PERCENT) { + next_percent = 80; + } else if (direction < 0 && percent == 80) { + next_percent = GUI_UI_SCALE_MIN_PERCENT; + } else { + next_percent = percent + + ((direction > 0) ? GUI_UI_SCALE_STEP_PERCENT + : -GUI_UI_SCALE_STEP_PERCENT); + } + + if (next_percent < GUI_UI_SCALE_MIN_PERCENT) { + next_percent = GUI_UI_SCALE_MIN_PERCENT; + } + if (next_percent > GUI_UI_SCALE_MAX_PERCENT) { + next_percent = GUI_UI_SCALE_MAX_PERCENT; + } + return next_percent; +} + +float gui_ui_scale_hud_opacity(double remaining_seconds) +{ + if (remaining_seconds <= 0.0) return 0.0f; + if (remaining_seconds >= GUI_UI_SCALE_HUD_FADE_S) return 1.0f; + return (float)(remaining_seconds / GUI_UI_SCALE_HUD_FADE_S); +} + +int gui_ui_modal_max_extent(int layout_extent, int configured_max) +{ + int available = layout_extent - (GUI_UI_MODAL_MARGIN * 2); + if (available < 1) available = 1; + if (configured_max < 1) configured_max = 1; + return (available < configured_max) ? available : configured_max; +} + +bool gui_ui_toolbar_uses_two_rows(int layout_width, + int single_row_required_width) +{ + return layout_width < single_row_required_width; +} + +gui_ui_status_layout_mode_t gui_ui_get_status_layout_mode(int layout_width, + int layout_height, + bool is_recording) +{ + bool quarter_scale = + layout_width <= GUI_UI_STATUS_QUARTER_MAX_WIDTH && + layout_height <= GUI_UI_STATUS_QUARTER_MAX_HEIGHT; + + if (layout_width < GUI_UI_STATUS_TINY_BREAKPOINT || quarter_scale || + (is_recording && + layout_width < GUI_UI_STATUS_RECORDING_MINIMAL_BREAKPOINT)) { + return GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE; + } + if (layout_width < GUI_UI_STATUS_COMPACT_BREAKPOINT || + (is_recording && + layout_width < GUI_UI_STATUS_RECORDING_FULL_BREAKPOINT)) { + return GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE; + } + return GUI_UI_STATUS_LAYOUT_FULL_SINGLE; +} + +bool gui_ui_status_uses_two_rows(gui_ui_status_layout_mode_t layout_mode, + bool status_is_error) +{ + return status_is_error && + layout_mode != GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE; +} + +bool gui_ui_status_shows_extended_counters(int layout_width, + bool is_recording) +{ + int breakpoint = is_recording + ? GUI_UI_STATUS_RECORDING_NARROW_BREAKPOINT + : GUI_UI_STATUS_NARROW_BREAKPOINT; + return layout_width >= breakpoint; +} + +gui_ui_zoom_result_t gui_ui_zoom_process(gui_ui_zoom_state_t *state, + int current_percent, + bool primary_modifier_down, + float wheel_x, + float wheel_y) +{ + gui_ui_zoom_result_t result = { + .percent = gui_ui_scale_sanitize_percent(current_percent), + .passthrough_x = wheel_x, + .passthrough_y = wheel_y, + .consumed = false, + .step_attempted = false, + .changed = false, + }; + + if (!state) return result; + + if (!primary_modifier_down) { + state->wheel_remainder = 0.0f; + return result; + } + + // Idle frames keep a partial trackpad gesture alive until more vertical + // motion arrives. A horizontal-dominant gesture remains normal scrolling + // and cancels any earlier vertical remainder. + if (wheel_y == 0.0f) { + if (wheel_x != 0.0f) state->wheel_remainder = 0.0f; + return result; + } + if (fabsf(wheel_x) > fabsf(wheel_y)) { + state->wheel_remainder = 0.0f; + return result; + } + + result.consumed = true; + result.passthrough_x = 0.0f; + result.passthrough_y = 0.0f; + + // Do not make a small trackpad movement in the opposite direction fight a + // previously accumulated gesture. + if ((state->wheel_remainder > 0.0f && wheel_y < 0.0f) || + (state->wheel_remainder < 0.0f && wheel_y > 0.0f)) { + state->wheel_remainder = 0.0f; + } + state->wheel_remainder += wheel_y; + + while (state->wheel_remainder >= 1.0f || + state->wheel_remainder <= -1.0f) { + result.step_attempted = true; + int direction = (state->wheel_remainder > 0.0f) ? 1 : -1; + int next_percent = gui_ui_scale_step_percent(result.percent, direction); + + if (next_percent == result.percent) { + state->wheel_remainder = 0.0f; + break; + } + + result.percent = next_percent; + result.changed = true; + state->wheel_remainder -= (float)direction; + + if (result.percent == GUI_UI_SCALE_MIN_PERCENT || + result.percent == GUI_UI_SCALE_MAX_PERCENT) { + state->wheel_remainder = 0.0f; + break; + } + } + + return result; +} diff --git a/misrc_tools/misrc_gui/ui/gui_ui_scale.h b/misrc_tools/misrc_gui/ui/gui_ui_scale.h new file mode 100644 index 0000000..7db1f5f --- /dev/null +++ b/misrc_tools/misrc_gui/ui/gui_ui_scale.h @@ -0,0 +1,93 @@ +#ifndef GUI_UI_SCALE_H +#define GUI_UI_SCALE_H + +#include + +#define GUI_UI_SCALE_MIN_PERCENT 75 +#define GUI_UI_SCALE_MAX_PERCENT 200 +#define GUI_UI_SCALE_DEFAULT_PERCENT 100 +#define GUI_UI_SCALE_STEP_PERCENT 10 +#define GUI_UI_SCALE_HUD_DURATION_S 1.5 +#define GUI_UI_SCALE_HUD_FADE_S 0.3 +#define GUI_UI_MODAL_MARGIN 12 +#define GUI_UI_STATUS_COMPACT_BREAKPOINT 1450 +#define GUI_UI_STATUS_RECORDING_FULL_BREAKPOINT 1500 +#define GUI_UI_STATUS_RECORDING_MINIMAL_BREAKPOINT 920 +#define GUI_UI_STATUS_NARROW_BREAKPOINT 960 +#define GUI_UI_STATUS_RECORDING_NARROW_BREAKPOINT 1100 +#define GUI_UI_STATUS_TINY_BREAKPOINT 760 +#define GUI_UI_STATUS_QUARTER_MAX_WIDTH 1000 +#define GUI_UI_STATUS_QUARTER_MAX_HEIGHT 700 + +typedef enum gui_ui_status_layout_mode { + GUI_UI_STATUS_LAYOUT_FULL_SINGLE, + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE, +} gui_ui_status_layout_mode_t; + +typedef struct gui_ui_zoom_state { + float wheel_remainder; +} gui_ui_zoom_state_t; + +typedef struct gui_ui_zoom_result { + int percent; + float passthrough_x; + float passthrough_y; + bool consumed; + bool step_attempted; + bool changed; +} gui_ui_zoom_result_t; + +// Invalid persisted values fall back to 100% so a damaged settings file cannot +// leave the UI permanently too small or too large to operate. +int gui_ui_scale_sanitize_percent(int percent); + +// Parses the integer JSON value used by settings persistence. Malformed, +// trailing, or out-of-range input returns the safe 100% default. +int gui_ui_scale_parse_percent(const char *text); + +// Applies one keyboard/wheel zoom step while preserving the special 75%-80% +// transition and the configured scale bounds. A zero direction is a no-op. +int gui_ui_scale_step_percent(int current_percent, int direction); + +// Returns the transient zoom HUD opacity from its remaining display time. +// The HUD stays opaque until the final fade interval, then reaches zero at +// the deadline. +float gui_ui_scale_hud_opacity(double remaining_seconds); + +// Caps a modal extent to the scale-adjusted logical viewport while retaining +// a small margin on both sides. The result is always at least one pixel. +int gui_ui_modal_max_extent(int layout_extent, int configured_max); + +// Returns true when the measured single-row toolbar would exceed the +// scale-adjusted logical viewport. +bool gui_ui_toolbar_uses_two_rows(int layout_width, + int single_row_required_width); + +// Chooses a deterministic status-bar layout from scale-adjusted logical +// dimensions. Recording reserves extra width for its timer/runway, while +// labels compact and lower-priority counters disappear before overflow. Very +// small/short layouts keep one minimal row to preserve plot height. +gui_ui_status_layout_mode_t gui_ui_get_status_layout_mode(int layout_width, + int layout_height, + bool is_recording); + +// Error text is the only state important enough to add a second row. Minimal +// layouts remain single-row to protect the remaining plot height. +bool gui_ui_status_uses_two_rows(gui_ui_status_layout_mode_t layout_mode, + bool status_is_error); + +// Extended frame/missed/error counters are hidden below this width so a +// normal compact status bar can remain on one line. +bool gui_ui_status_shows_extended_counters(int layout_width, + bool is_recording); + +// Routes one frame of wheel input. Vertical Ctrl/Cmd+wheel is accumulated into +// discrete scale steps and consumed so it cannot also scroll Clay or a panel. +gui_ui_zoom_result_t gui_ui_zoom_process(gui_ui_zoom_state_t *state, + int current_percent, + bool primary_modifier_down, + float wheel_x, + float wheel_y); + +#endif // GUI_UI_SCALE_H diff --git a/misrc_tools/misrc_gui/visualization/gui_fft.c b/misrc_tools/misrc_gui/visualization/gui_fft.c index 4a0605c..42f9e6d 100644 --- a/misrc_tools/misrc_gui/visualization/gui_fft.c +++ b/misrc_tools/misrc_gui/visualization/gui_fft.c @@ -635,7 +635,9 @@ void gui_fft_render(fft_state_t *state, float x, float y, int rt_height = (int)height; // Ensure we have phosphor render textures of the right size - if (!phosphor_rt_init(&state->phosphor, rt_width, rt_height)) { + Vector2 render_scale = gui_ui_get_render_scale(); + if (!phosphor_rt_init(&state->phosphor, rt_width, rt_height, + render_scale.x, render_scale.y)) { // Fallback: just draw background DrawRectangle((int)x, (int)y, rt_width, rt_height, COLOR_METER_BG); fft_draw_text(fonts, "FFT: GPU init failed", x + 10, y + 10, FONT_SIZE_OSC_SCALE, (Color){255, 80, 80, 255}); @@ -810,7 +812,7 @@ void gui_fft_render(fft_state_t *state, float x, float y, // Peak detection on mouse hover - show closest peak to mouse (with zoom/pan support) if (state->peak_hold && state->data_ready && state->fft_bins > 1 && sample_rate > 0) { - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); Rectangle fft_rect = {x, y, width, height}; if (CheckCollisionPointRec(mouse, fft_rect)) { @@ -1008,7 +1010,7 @@ static bool fft_handle_scroll(void *state_ptr, float delta, Rectangle bounds) { fft_state_t *state = (fft_state_t *)state_ptr; if (!state->initialized) return false; - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); // Check if mouse is inside bounds if (!CheckCollisionPointRec(mouse, bounds)) return false; @@ -1093,7 +1095,7 @@ static void fft_update_drag(fft_state_t *state, Rectangle bounds) { #if LIBFFTW_ENABLED if (!state || !state->initialized || !state->dragging) return; - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); if (IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { // Calculate pan delta based on mouse movement diff --git a/misrc_tools/misrc_gui/visualization/gui_histogram_panel.c b/misrc_tools/misrc_gui/visualization/gui_histogram_panel.c index deb747d..deca8fd 100644 --- a/misrc_tools/misrc_gui/visualization/gui_histogram_panel.c +++ b/misrc_tools/misrc_gui/visualization/gui_histogram_panel.c @@ -351,7 +351,7 @@ static void histogram_vtable_render_overlay(void *state_ptr, Rectangle bounds) { state->options_rect[i] = opt_rect; bool is_selected = (hist->num_bins == s_bin_options[i]); - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); bool hover = CheckCollisionPointRec(mouse, opt_rect); Color opt_bg = gui_dropdown_option_color(is_selected, hover); diff --git a/misrc_tools/misrc_gui/visualization/gui_oscilloscope.c b/misrc_tools/misrc_gui/visualization/gui_oscilloscope.c index 48df6df..99199a5 100644 --- a/misrc_tools/misrc_gui/visualization/gui_oscilloscope.c +++ b/misrc_tools/misrc_gui/visualization/gui_oscilloscope.c @@ -525,7 +525,9 @@ static void render_waveform_phosphor_internal(waveform_panel_state_t *state, phosphor_rt_t *prt = state->phosphor; if (prt) { // Initialize/resize phosphor if needed - phosphor_rt_init(prt, buf_width, buf_height); + Vector2 render_scale = gui_ui_get_render_scale(); + phosphor_rt_init(prt, buf_width, buf_height, + render_scale.x, render_scale.y); // Update phosphor phosphor_rt_begin_frame(prt); @@ -965,7 +967,7 @@ static void waveform_render_overlay(void *state_ptr, Rectangle bounds) { float btn_h = 18; float btn_y = bounds.y + 8; - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); // Button widths float trig_btn_w = 98; @@ -1240,7 +1242,7 @@ static void waveform_panel_update_drag(waveform_panel_state_t *state, gui_app_t if (!state || !app || !state->dragging) return; if (IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); // Calculate trigger level from mouse position float center_y = bounds.y + bounds.height / 2.0f; @@ -1266,7 +1268,7 @@ static bool waveform_panel_handle_scroll(void *state_ptr, float delta, Rectangle waveform_panel_state_t *state = (waveform_panel_state_t *)state_ptr; - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); if (!CheckCollisionPointRec(mouse, bounds)) return false; // Smooth zoom: multiply/divide by a factor for each scroll step diff --git a/misrc_tools/misrc_gui/visualization/gui_panel.c b/misrc_tools/misrc_gui/visualization/gui_panel.c index ca2f3f3..503b01c 100644 --- a/misrc_tools/misrc_gui/visualization/gui_panel.c +++ b/misrc_tools/misrc_gui/visualization/gui_panel.c @@ -288,7 +288,7 @@ bool panel_handle_all_clicks(gui_app_t *app, Vector2 mouse_pos) { static bool try_panel_scroll(panel_view_type_t type, void *state, Rectangle bounds, float delta) { // First check if mouse is within bounds - Vector2 mouse = GetMousePosition(); + Vector2 mouse = gui_ui_get_mouse_position(); if (!CheckCollisionPointRec(mouse, bounds)) { return false; } diff --git a/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.c b/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.c index 354d5cf..390b45b 100644 --- a/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.c +++ b/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.c @@ -6,6 +6,7 @@ #include "gui_phosphor_rt.h" #include "rlgl.h" +#include #include //----------------------------------------------------------------------------- @@ -262,15 +263,27 @@ void phosphor_rt_cleanup_shaders(void) { // Render Texture Lifecycle //----------------------------------------------------------------------------- -bool phosphor_rt_init(phosphor_rt_t *prt, int width, int height) { +bool phosphor_rt_init(phosphor_rt_t *prt, int logical_width, + int logical_height, float render_scale_x, + float render_scale_y) { if (!prt) return false; - // Clamp dimensions + // Clamp dimensions and derive display-pixel-sized render textures. Keeping + // this buffer at physical density avoids blurring at >100% and needless + // over-allocation at <100% while the rest of the UI remains logical. + if (logical_width < 1) logical_width = 1; + if (logical_height < 1) logical_height = 1; + if (!isfinite(render_scale_x) || render_scale_x <= 0.0f) render_scale_x = 1.0f; + if (!isfinite(render_scale_y) || render_scale_y <= 0.0f) render_scale_y = 1.0f; + int width = (int)lroundf((float)logical_width * render_scale_x); + int height = (int)lroundf((float)logical_height * render_scale_y); if (width < 1) width = 1; if (height < 1) height = 1; // Check if resize needed - if (prt->valid && prt->width == width && prt->height == height) { + if (prt->valid && prt->width == width && prt->height == height && + prt->logical_width == logical_width && + prt->logical_height == logical_height) { return true; // Already correct size } @@ -301,12 +314,19 @@ bool phosphor_rt_init(phosphor_rt_t *prt, int width, int height) { SetTextureFilter(prt->rt[0].texture, TEXTURE_FILTER_BILINEAR); SetTextureFilter(prt->rt[1].texture, TEXTURE_FILTER_BILINEAR); + // EndTextureMode() resets raylib's model-view matrix. Preserve an outer UI + // camera when initialization happens during a Clay custom render command. + Matrix outer_modelview = rlGetMatrixModelview(); + // Clear render textures BeginTextureMode(prt->rt[0]); ClearBackground(BLACK); EndTextureMode(); BeginTextureMode(prt->rt[1]); ClearBackground(BLACK); EndTextureMode(); + rlSetMatrixModelview(outer_modelview); prt->width = width; prt->height = height; + prt->logical_width = logical_width; + prt->logical_height = logical_height; prt->rt_index = 0; prt->valid = true; @@ -321,15 +341,19 @@ bool phosphor_rt_init(phosphor_rt_t *prt, int width, int height) { prt->config.bloom_intensity = PHOSPHOR_DEFAULT_BLOOM; } - TraceLog(LOG_INFO, "PHOSPHOR_RT: Initialized %dx%d render textures", width, height); + TraceLog(LOG_INFO, + "PHOSPHOR_RT: Initialized %dx%d render textures for %dx%d logical pixels", + width, height, logical_width, logical_height); return true; } void phosphor_rt_clear(phosphor_rt_t *prt) { if (!prt || !prt->valid) return; + Matrix outer_modelview = rlGetMatrixModelview(); BeginTextureMode(prt->rt[0]); ClearBackground(BLACK); EndTextureMode(); BeginTextureMode(prt->rt[1]); ClearBackground(BLACK); EndTextureMode(); + rlSetMatrixModelview(outer_modelview); } void phosphor_rt_cleanup(phosphor_rt_t *prt) { @@ -342,7 +366,10 @@ void phosphor_rt_cleanup(phosphor_rt_t *prt) { } prt->width = 0; prt->height = 0; + prt->logical_width = 0; + prt->logical_height = 0; prt->rt_index = 0; + prt->outer_modelview_saved = false; } void phosphor_rt_set_config(phosphor_rt_t *prt, const phosphor_rt_config_t *config) { @@ -383,6 +410,8 @@ void phosphor_rt_begin_frame(phosphor_rt_t *prt) { int next = 1 - current; // Apply decay to previous frame, write to next buffer + prt->outer_modelview = rlGetMatrixModelview(); + prt->outer_modelview_saved = true; BeginTextureMode(prt->rt[next]); ClearBackground(BLACK); @@ -397,6 +426,9 @@ void phosphor_rt_begin_frame(phosphor_rt_t *prt) { // Now ready for drawing new primitives with additive blending BeginBlendMode(BLEND_ADDITIVE); + rlScalef((float)prt->width / (float)prt->logical_width, + (float)prt->height / (float)prt->logical_height, + 1.0f); } void phosphor_rt_end_frame(phosphor_rt_t *prt) { @@ -404,6 +436,10 @@ void phosphor_rt_end_frame(phosphor_rt_t *prt) { EndBlendMode(); EndTextureMode(); + if (prt->outer_modelview_saved) { + rlSetMatrixModelview(prt->outer_modelview); + prt->outer_modelview_saved = false; + } // Swap buffers prt->rt_index = 1 - prt->rt_index; @@ -422,9 +458,12 @@ void phosphor_rt_render(phosphor_rt_t *prt, float x, float y, bool use_alpha_ble } BeginShaderMode(s_composite_shader); - DrawTextureRec(prt->rt[prt->rt_index].texture, + DrawTexturePro(prt->rt[prt->rt_index].texture, (Rectangle){0, 0, (float)prt->width, -(float)prt->height}, - (Vector2){x, y}, WHITE); + (Rectangle){x, y, + (float)prt->logical_width, + (float)prt->logical_height}, + (Vector2){0, 0}, 0.0f, WHITE); EndShaderMode(); if (use_alpha_blend) { @@ -442,9 +481,12 @@ void phosphor_rt_render_opacity(phosphor_rt_t *prt, float x, float y) { SetShaderValue(s_opacity_shader, s_opacity_bloomIntensity_loc, &bloom, SHADER_UNIFORM_FLOAT); BeginShaderMode(s_opacity_shader); - DrawTextureRec(prt->rt[prt->rt_index].texture, + DrawTexturePro(prt->rt[prt->rt_index].texture, (Rectangle){0, 0, (float)prt->width, -(float)prt->height}, - (Vector2){x, y}, WHITE); + (Rectangle){x, y, + (float)prt->logical_width, + (float)prt->logical_height}, + (Vector2){0, 0}, 0.0f, WHITE); EndShaderMode(); } @@ -470,7 +512,7 @@ void phosphor_rt_draw_waveform(phosphor_rt_t *prt, float amplitude_scale) { if (!prt || !prt->valid || !samples || sample_count < 2) return; - int buf_height = prt->height; + int buf_height = prt->logical_height; // Scale factor: half height = full amplitude float scale = amplitude_scale * 0.5f; diff --git a/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.h b/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.h index bd96c63..c41ba64 100644 --- a/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.h +++ b/misrc_tools/misrc_gui/visualization/gui_phosphor_rt.h @@ -48,9 +48,13 @@ typedef struct phosphor_rt_config { typedef struct phosphor_rt { RenderTexture2D rt[2]; // Ping-pong render textures int rt_index; // Current render texture index (0 or 1) - int width; // Render texture width - int height; // Render texture height + int width; // Physical render texture width + int height; // Physical render texture height + int logical_width; // Destination width before global UI scaling + int logical_height; // Destination height before global UI scaling bool valid; // True if render textures are initialized + Matrix outer_modelview; // UI transform saved across Begin/EndTextureMode + bool outer_modelview_saved; phosphor_rt_config_t config; // Per-instance configuration } phosphor_rt_t; @@ -69,9 +73,12 @@ void phosphor_rt_cleanup_shaders(void); // Render Texture Lifecycle //----------------------------------------------------------------------------- -// Initialize or resize a phosphor render texture pair +// Initialize or resize a phosphor render texture pair. Dimensions are logical +// UI pixels; render scales keep the off-screen texture at framebuffer density. // Returns true on success, false on allocation failure -bool phosphor_rt_init(phosphor_rt_t *prt, int width, int height); +bool phosphor_rt_init(phosphor_rt_t *prt, int logical_width, + int logical_height, float render_scale_x, + float render_scale_y); // Clear render textures (reset to black) void phosphor_rt_clear(phosphor_rt_t *prt); diff --git a/misrc_tools/test/ci_guard_tests.py b/misrc_tools/test/ci_guard_tests.py index 3f3e275..a884c1e 100644 --- a/misrc_tools/test/ci_guard_tests.py +++ b/misrc_tools/test/ci_guard_tests.py @@ -329,23 +329,23 @@ def check_linux_desktop_metadata(workflow_path: Path) -> int: return 0 -def check_macos_layout_policy(gui_c_path: Path) -> int: - source = read_text(gui_c_path) - width_body = extract_function_body(source, "static int gui_layout_width(void)") - height_body = extract_function_body(source, "static int gui_layout_height(void)") +def check_macos_layout_policy(gui_ui_c_path: Path) -> int: + source = read_text(gui_ui_c_path) + width_body = extract_function_body(source, "static int gui_ui_get_base_layout_width(void)") + height_body = extract_function_body(source, "static int gui_ui_get_base_layout_height(void)") if "#if defined(__APPLE__)" not in width_body: - return fail("gui_layout_width() is missing __APPLE__ guard") + return fail("gui_ui_get_base_layout_width() is missing __APPLE__ guard") if "#if defined(__APPLE__)" not in height_body: - return fail("gui_layout_height() is missing __APPLE__ guard") + return fail("gui_ui_get_base_layout_height() is missing __APPLE__ guard") if "GetScreenWidth();" not in width_body: - return fail("gui_layout_width() must use GetScreenWidth() on macOS") + return fail("gui_ui_get_base_layout_width() must use GetScreenWidth() on macOS") if "GetScreenHeight();" not in height_body: - return fail("gui_layout_height() must use GetScreenHeight() on macOS") + return fail("gui_ui_get_base_layout_height() must use GetScreenHeight() on macOS") if "GetRenderWidth();" not in width_body: - return fail("gui_layout_width() must use GetRenderWidth() for non-macOS") + return fail("gui_ui_get_base_layout_width() must use GetRenderWidth() for non-macOS") if "GetRenderHeight();" not in height_body: - return fail("gui_layout_height() must use GetRenderHeight() for non-macOS") + return fail("gui_ui_get_base_layout_height() must use GetRenderHeight() for non-macOS") return 0 def check_macos_admin_elevation_contract(gui_c_path: Path) -> int: @@ -1342,6 +1342,148 @@ def check_record_ringbuffer_fallback_runtime(repo_root: Path) -> int: return 0 +def check_ui_scale_policy_runtime(repo_root: Path) -> int: + cc = shutil.which("cc") + if cc is None: + if os.environ.get("GITHUB_ACTIONS", "").lower() == "true": + return fail("C compiler 'cc' is required for UI scale policy runtime guard") + print("SKIP: UI scale policy runtime guard (cc not available)") + return 0 + + harness_path = repo_root / "misrc_tools/test/gui_ui_scale_harness.c" + policy_path = repo_root / "misrc_tools/misrc_gui/ui/gui_ui_scale.c" + include_dir = repo_root / "misrc_tools/misrc_gui/ui" + + for path, label in [(harness_path, "UI scale harness"), + (policy_path, "UI scale policy")]: + if not path.exists(): + return fail(f"{label} source is missing: {path}") + + with tempfile.TemporaryDirectory(prefix="misrc_ui_scale_guard_") as temp_root: + exe_name = "gui_ui_scale_guard.exe" if os.name == "nt" else "gui_ui_scale_guard" + exe_path = Path(temp_root) / exe_name + compile_cmd = [ + cc, + "-std=c11", + "-Wall", + "-Wextra", + f"-I{include_dir}", + str(harness_path), + str(policy_path), + "-lm", + "-o", + str(exe_path), + ] + try: + run_checked(compile_cmd) + run_checked([str(exe_path)]) + except subprocess.CalledProcessError as exc: + return fail( + "UI scale policy runtime guard failed\n" + f"stdout:\n{exc.stdout}\n" + f"stderr:\n{exc.stderr}" + ) + return 0 + + +def check_ui_scale_integration_contract(repo_root: Path, gui_c_path: Path, + gui_settings_c_path: Path, + meson_path: Path) -> int: + gui_c = read_text(gui_c_path) + settings_c = read_text(gui_settings_c_path) + gui_app_h = read_text(repo_root / "misrc_tools/misrc_gui/core/gui_app.h") + gui_ui_h = read_text(repo_root / "misrc_tools/misrc_gui/ui/gui_ui.h") + gui_ui_c = read_text(repo_root / "misrc_tools/misrc_gui/ui/gui_ui.c") + popup_c = read_text(repo_root / "misrc_tools/misrc_gui/ui/gui_popup.c") + renderer_c = read_text(repo_root / "misrc_tools/misrc_gui/ui/clay_renderer_raylib.c") + oscilloscope_c = read_text(repo_root / "misrc_tools/misrc_gui/visualization/gui_oscilloscope.c") + fft_c = read_text(repo_root / "misrc_tools/misrc_gui/visualization/gui_fft.c") + phosphor_h = read_text(repo_root / "misrc_tools/misrc_gui/visualization/gui_phosphor_rt.h") + phosphor_c = read_text(repo_root / "misrc_tools/misrc_gui/visualization/gui_phosphor_rt.c") + meson = read_text(meson_path) + + required_snippets = [ + (gui_app_h, "int ui_scale_percent;", "persisted settings field"), + (settings_c, "settings->ui_scale_percent = GUI_UI_SCALE_DEFAULT_PERCENT;", "100% default"), + (settings_c, '\\"ui_scale_percent\\": %d', "settings save key"), + (settings_c, "gui_ui_scale_parse_percent(value)", "validated settings load"), + (meson, "'misrc_gui/ui/gui_ui_scale.c'", "UI scale policy product source"), + (gui_c, "gui_ui_zoom_process(&ui_zoom_state", "single wheel routing policy"), + (gui_c, "IsKeyPressed(KEY_ZERO) || IsKeyPressed(KEY_KP_0)", "100% reset shortcut"), + (gui_c, "IsKeyPressed(KEY_EQUAL) || IsKeyPressed(KEY_KP_ADD)", "keyboard zoom-in shortcut"), + (gui_c, "IsKeyPressed(KEY_MINUS) || IsKeyPressed(KEY_KP_SUBTRACT)", "keyboard zoom-out shortcut"), + (gui_c, "gui_ui_scale_step_percent(app.settings.ui_scale_percent", "shared keyboard zoom-step policy"), + (gui_c, "ui_zoom_result.step_attempted || keyboard_zoom_pressed", "zoom HUD attempt feedback"), + (gui_c, "gui_ui_show_scale_hud(ui_zoom_result.percent);", "zoom HUD trigger"), + (gui_c, "ui_zoom_result.passthrough_x * 20.0f", "Clay horizontal wheel routing"), + (gui_c, "ui_zoom_result.passthrough_y * 20.0f", "Clay vertical wheel routing"), + (gui_ui_h, "Vector2 gui_ui_get_mouse_position(void);", "logical pointer API"), + (gui_ui_c, "position.x /= scale;", "pointer inverse transform"), + (gui_ui_c, "CLAY_POINTER_CAPTURE_MODE_PASSTHROUGH", "pointer-transparent zoom HUD"), + (gui_ui_c, "gui_ui_scale_hud_opacity(remaining_s)", "zoom HUD fade policy"), + (gui_ui_c, "render_ui_scale_hud();", "zoom HUD render integration"), + (gui_ui_c, "Cmd+0 to reset to 100%", "macOS zoom reset hint"), + (gui_ui_c, "Ctrl+0 to reset to 100%", "desktop zoom reset hint"), + (gui_ui_c, "gui_ui_toolbar_uses_two_rows(toolbar_width,", "content-aware toolbar policy"), + (gui_ui_c, "gui_ui_get_status_layout_mode(status_width, status_height,", "responsive status policy"), + (gui_ui_c, 'strstr(raw_status_gate, "Capture stopped:")', "critical stop reason priority"), + (gui_ui_c, "gui_ui_modal_max_extent(gui_ui_get_layout_width()", "viewport-clamped modals"), + (popup_c, "gui_ui_modal_max_extent(gui_ui_get_layout_width()", "viewport-clamped generic popup"), + (popup_c, 'CLAY_ID("PopupContentScroll")', "scrollable popup content"), + (renderer_c, "Matrix outer_modelview = rlGetMatrixModelview();", "outer render transform capture"), + (renderer_c, "rlScalef(ui_scale, ui_scale, 1.0f);", "global render transform"), + (renderer_c, "box.x * ui_scale", "scaled scissor transform"), + (renderer_c, "rlSetMatrixModelview(outer_modelview);", "balanced render transform"), + (phosphor_h, "Matrix outer_modelview;", "saved phosphor model-view"), + (phosphor_c, "rlGetMatrixModelview()", "phosphor transform capture"), + (phosphor_c, "rlSetMatrixModelview", "phosphor transform restore"), + (phosphor_c, "DrawTexturePro", "logical-size phosphor composite"), + (gui_ui_h, "Vector2 gui_ui_get_render_scale(void);", "framebuffer-density API"), + (oscilloscope_c, "gui_ui_get_render_scale();", "scale-aware waveform texture"), + (fft_c, "gui_ui_get_render_scale();", "scale-aware FFT texture"), + ] + for source, snippet, label in required_snippets: + if snippet not in source: + return fail(f"Missing UI scale integration contract ({label}): {snippet}") + + if gui_c.count("GetMouseWheelMoveV(") != 1: + return fail("misrc_gui.c must snapshot GetMouseWheelMoveV() exactly once per frame") + if re.search(r"\bGetMouseWheelMove\(", gui_c): + return fail("misrc_gui.c must not re-read scalar GetMouseWheelMove()") + + ordered = [ + gui_c.find("GetMouseWheelMoveV("), + gui_c.find("gui_ui_zoom_process(&ui_zoom_state"), + gui_c.find("Clay_UpdateScrollContainers"), + gui_c.find("panel_handle_all_scrolls"), + ] + if any(pos < 0 for pos in ordered) or ordered != sorted(ordered): + return fail("UI scale wheel routing must occur before Clay and panel consumers") + + modifier_snippets = [ + "KEY_LEFT_CONTROL", "KEY_RIGHT_CONTROL", + "KEY_LEFT_SUPER", "KEY_RIGHT_SUPER", + ] + for snippet in modifier_snippets: + if snippet not in gui_c: + return fail(f"UI scale primary modifier mapping is missing {snippet}") + + direct_mouse_calls = [] + gui_root = repo_root / "misrc_tools/misrc_gui" + for source_path in gui_root.rglob("*.c"): + source = read_text(source_path) + count = source.count("GetMousePosition(") + if count: + direct_mouse_calls.append((source_path, count)) + expected_pointer_source = repo_root / "misrc_tools/misrc_gui/ui/gui_ui.c" + if direct_mouse_calls != [(expected_pointer_source, 1)]: + details = ", ".join(f"{path.relative_to(repo_root)}:{count}" + for path, count in direct_mouse_calls) + return fail(f"Raw GetMousePosition() escaped the logical pointer wrapper: {details}") + + return 0 + + def main() -> int: parser = argparse.ArgumentParser(description="MISRC CI guard tests") parser.add_argument( @@ -1368,6 +1510,7 @@ def main() -> int: legacy_workflow_path = repo_root / ".github/workflows/release-sanity-build.yml" gui_c_path = repo_root / "misrc_tools/misrc_gui/core/misrc_gui.c" gui_settings_c_path = repo_root / "misrc_tools/misrc_gui/core/gui_settings.c" + gui_ui_c_path = repo_root / "misrc_tools/misrc_gui/ui/gui_ui.c" flac_writer_c_path = repo_root / "misrc_tools/common/flac_writer.c" meson_path = repo_root / "misrc_tools/meson.build" tools_readme_path = repo_root / "misrc_tools/README.md" @@ -1385,7 +1528,7 @@ def main() -> int: ("meson FX3 native-build policy", lambda: check_meson_fx3_policy(meson_path)), ("cross-platform smoke tests", lambda: check_cross_platform_smoke_tests(workflow_path)), ("linux desktop metadata", lambda: check_linux_desktop_metadata(workflow_path)), - ("macOS layout policy", lambda: check_macos_layout_policy(gui_c_path)), + ("macOS layout policy", lambda: check_macos_layout_policy(gui_ui_c_path)), ("macOS startup admin elevation contract", lambda: check_macos_admin_elevation_contract(gui_c_path)), ("Windows meson subsystem contract", lambda: check_windows_meson_subsystem_contract(meson_path)), ("dev version naming", lambda: check_dev_version_naming(repo_root, meson_path, workflow_path)), @@ -1394,6 +1537,8 @@ def main() -> int: ("optional-dep guard consistency", lambda: check_optional_dep_guard_consistency(repo_root)), ("debug-view runtime contract", lambda: check_debug_view_contract(gui_c_path)), ("settings persistence contract", lambda: check_settings_persistence_contract(gui_settings_c_path)), + ("UI scale integration contract", lambda: check_ui_scale_integration_contract( + repo_root, gui_c_path, gui_settings_c_path, meson_path)), ("FLAC large-file offsets contract", lambda: check_flac_large_file_offsets_contract(flac_writer_c_path)), ("AppRun static contract", lambda: check_apprun_static_contract(workflow_path)), ("Windows packaging assertions", lambda: check_windows_packaging_assertions(workflow_path)), @@ -1409,7 +1554,8 @@ def main() -> int: if not args.static_only: checks.insert(7, ("AppRun runtime behavior", lambda: check_apprun_runtime_behavior(workflow_path, icon_path))) checks.insert(8, ("record ringbuffer fallback runtime", lambda: check_record_ringbuffer_fallback_runtime(repo_root))) - checks.insert(9, ("built GUI links vendored hsdaoh", lambda: check_built_gui_links_vendored_hsdaoh(repo_root, args.gui_path))) + checks.insert(9, ("UI scale policy runtime", lambda: check_ui_scale_policy_runtime(repo_root))) + checks.insert(10, ("built GUI links vendored hsdaoh", lambda: check_built_gui_links_vendored_hsdaoh(repo_root, args.gui_path))) # --post-build: always run the binary-introspection guards against the real # built misrc_gui (passed via --gui-path by CI build jobs). This is the mode # that catches vendored-dep shadowing and silent FX3-disable on every build. diff --git a/misrc_tools/test/gui_ui_scale_harness.c b/misrc_tools/test/gui_ui_scale_harness.c new file mode 100644 index 0000000..20627ac --- /dev/null +++ b/misrc_tools/test/gui_ui_scale_harness.c @@ -0,0 +1,253 @@ +#include "gui_ui_scale.h" + +#include +#include + +static int failures = 0; + +static void expect_true(int condition, const char *message) +{ + if (condition) return; + fprintf(stderr, "FAIL: %s\n", message); + failures++; +} + +static void expect_float(float actual, float expected, const char *message) +{ + if (fabsf(actual - expected) < 0.0001f) return; + fprintf(stderr, "FAIL: %s (actual %.4f, expected %.4f)\n", + message, (double)actual, (double)expected); + failures++; +} + +int main(void) +{ + gui_ui_zoom_state_t state = {0}; + + expect_true(gui_ui_scale_sanitize_percent(75) == 75, + "minimum persisted scale remains valid"); + expect_true(gui_ui_scale_sanitize_percent(200) == 200, + "maximum persisted scale remains valid"); + expect_true(gui_ui_scale_sanitize_percent(0) == 100, + "invalid low persisted scale falls back to 100"); + expect_true(gui_ui_scale_sanitize_percent(999) == 100, + "invalid high persisted scale falls back to 100"); + expect_true(gui_ui_scale_sanitize_percent(137) == 100, + "persisted scale outside the supported steps falls back to 100"); + expect_true(gui_ui_scale_parse_percent("150") == 150, + "valid persisted scale parses"); + expect_true(gui_ui_scale_parse_percent(NULL) == 100, + "missing persisted scale falls back to 100"); + expect_true(gui_ui_scale_parse_percent("") == 100, + "empty persisted scale falls back to 100"); + expect_true(gui_ui_scale_parse_percent("250") == 100, + "out-of-range persisted scale falls back to 100"); + expect_true(gui_ui_scale_parse_percent("125junk") == 100, + "persisted scale with trailing data is rejected"); + expect_true(gui_ui_scale_parse_percent("999999999999999999999") == 100, + "overflowed persisted scale falls back to 100"); + + expect_true(gui_ui_scale_step_percent(100, 1) == 110, + "keyboard zoom-in advances one scale step"); + expect_true(gui_ui_scale_step_percent(100, -1) == 90, + "keyboard zoom-out retreats one scale step"); + expect_true(gui_ui_scale_step_percent(75, 1) == 80, + "keyboard zoom-in preserves the 75-to-80 transition"); + expect_true(gui_ui_scale_step_percent(80, -1) == 75, + "keyboard zoom-out preserves the 80-to-75 transition"); + expect_true(gui_ui_scale_step_percent(200, 1) == 200, + "keyboard zoom-in stops at the upper bound"); + expect_true(gui_ui_scale_step_percent(75, -1) == 75, + "keyboard zoom-out stops at the lower bound"); + expect_true(gui_ui_scale_step_percent(150, 0) == 150, + "zero keyboard direction leaves scale unchanged"); + + expect_float((float)GUI_UI_SCALE_HUD_DURATION_S, + 1.5f, + "zoom HUD uses the requested 1.5-second lifetime"); + expect_float((float)GUI_UI_SCALE_HUD_FADE_S, + 0.3f, + "zoom HUD reserves the final 0.3 seconds for fading"); + expect_true(GUI_UI_SCALE_HUD_DURATION_S > GUI_UI_SCALE_HUD_FADE_S, + "zoom HUD lifetime is longer than its fade interval"); + expect_float(gui_ui_scale_hud_opacity(GUI_UI_SCALE_HUD_DURATION_S), + 1.0f, + "zoom HUD begins fully opaque"); + expect_float(gui_ui_scale_hud_opacity(GUI_UI_SCALE_HUD_FADE_S), + 1.0f, + "zoom HUD remains opaque until the fade interval"); + expect_float(gui_ui_scale_hud_opacity(GUI_UI_SCALE_HUD_FADE_S / 2.0), + 0.5f, + "zoom HUD fades linearly near its deadline"); + expect_float(gui_ui_scale_hud_opacity(0.0), + 0.0f, + "zoom HUD is hidden at its deadline"); + expect_float(gui_ui_scale_hud_opacity(-0.1), + 0.0f, + "zoom HUD remains hidden after its deadline"); + + expect_true(gui_ui_modal_max_extent(320, 1080) == 296, + "wide modal is clamped inside a 320px logical viewport"); + expect_true(gui_ui_modal_max_extent(180, 780) == 156, + "tall modal is clamped inside a 180px logical viewport"); + expect_true(gui_ui_modal_max_extent(1920, 1080) == 1080, + "modal keeps its configured cap on a wide viewport"); + expect_true(gui_ui_modal_max_extent(10, 420) == 1, + "modal extent remains valid on a degenerate viewport"); + + expect_true(gui_ui_toolbar_uses_two_rows(1239, 1300), + "2478px screenshot at 200 percent uses two toolbar rows"); + expect_true(gui_ui_toolbar_uses_two_rows(320, 500), + "extremely narrow logical layouts keep the compact toolbar policy"); + expect_true(gui_ui_toolbar_uses_two_rows(1425, 1450), + "long labels wrap when the default window cannot fit them"); + expect_true(!gui_ui_toolbar_uses_two_rows(1425, 1400), + "default window stays one row when its current labels fit"); + expect_true(!gui_ui_toolbar_uses_two_rows(1920, 1500), + "wide logical layouts keep the original single row"); + + expect_true(gui_ui_get_status_layout_mode(1090, 700, false) == + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "large-scale screenshot width uses compact single-row status"); + expect_true(gui_ui_get_status_layout_mode( + GUI_UI_STATUS_COMPACT_BREAKPOINT - 1, 900, false) == + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "width below full status breakpoint uses compact labels"); + expect_true(gui_ui_get_status_layout_mode( + GUI_UI_STATUS_COMPACT_BREAKPOINT, 900, false) == + GUI_UI_STATUS_LAYOUT_FULL_SINGLE, + "full status breakpoint restores the original labels"); + expect_true(gui_ui_get_status_layout_mode(1425, 900, false) == + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "default window compacts status labels before they overflow"); + expect_true(gui_ui_get_status_layout_mode(1425, 900, true) == + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "recording at the default width compacts the dense counter row"); + expect_true(gui_ui_get_status_layout_mode( + GUI_UI_STATUS_RECORDING_MINIMAL_BREAKPOINT - 1, + 900, true) == GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE, + "narrow recording layout hides runway and lower-priority groups"); + expect_true(gui_ui_get_status_layout_mode( + GUI_UI_STATUS_RECORDING_MINIMAL_BREAKPOINT, + 900, true) == GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "recording minimal breakpoint restores the compact row"); + expect_true(gui_ui_get_status_layout_mode( + GUI_UI_STATUS_RECORDING_FULL_BREAKPOINT, 900, true) == + GUI_UI_STATUS_LAYOUT_FULL_SINGLE, + "wide recording layout restores full status labels"); + expect_true(gui_ui_get_status_layout_mode(1000, 700, false) == + GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE, + "quarter-scale boundary preserves plot height with one minimal row"); + expect_true(gui_ui_get_status_layout_mode(1001, 700, false) == + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "one pixel beyond quarter width stays a compact single row"); + expect_true(gui_ui_get_status_layout_mode(1000, 701, false) == + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "one pixel beyond quarter height stays a compact single row"); + expect_true(gui_ui_get_status_layout_mode(759, 900, false) == + GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE, + "extremely narrow status bar remains a minimal single row"); + expect_true(gui_ui_get_status_layout_mode(760, 900, false) == + GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, + "tiny breakpoint itself remains a compact single row"); + expect_true(gui_ui_status_uses_two_rows(GUI_UI_STATUS_LAYOUT_FULL_SINGLE, true), + "wide status profile gives error text a dedicated row"); + expect_true(gui_ui_status_uses_two_rows(GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, true), + "compact status profile gives error text a dedicated row"); + expect_true(!gui_ui_status_uses_two_rows(GUI_UI_STATUS_LAYOUT_MINIMAL_SINGLE, true), + "minimal profile remains one row even when showing an error"); + expect_true(!gui_ui_status_uses_two_rows(GUI_UI_STATUS_LAYOUT_COMPACT_SINGLE, false), + "normal compact status remains one row"); + expect_true(!gui_ui_status_shows_extended_counters( + GUI_UI_STATUS_NARROW_BREAKPOINT - 1, false), + "narrow status hides lower-priority counters"); + expect_true(gui_ui_status_shows_extended_counters( + GUI_UI_STATUS_NARROW_BREAKPOINT, false), + "extended counters return at the narrow breakpoint"); + expect_true(!gui_ui_status_shows_extended_counters( + GUI_UI_STATUS_RECORDING_NARROW_BREAKPOINT - 1, true), + "recording keeps extended counters hidden until they fit"); + expect_true(gui_ui_status_shows_extended_counters( + GUI_UI_STATUS_RECORDING_NARROW_BREAKPOINT, true), + "recording restores extended counters at its wider breakpoint"); + + gui_ui_zoom_result_t result = + gui_ui_zoom_process(&state, 100, false, 0.25f, -0.5f); + expect_true(!result.consumed && !result.changed && result.percent == 100, + "plain wheel does not change UI scale"); + expect_float(result.passthrough_x, 0.25f, + "plain horizontal wheel passes through"); + expect_float(result.passthrough_y, -0.5f, + "plain vertical wheel passes through"); + + result = gui_ui_zoom_process(&state, 100, true, 0.0f, 0.4f); + expect_true(result.consumed && !result.step_attempted && + !result.changed && result.percent == 100, + "partial modified wheel is consumed but waits for a full step"); + result = gui_ui_zoom_process(&state, result.percent, true, 0.0f, 0.6f); + expect_true(result.consumed && result.step_attempted && + result.changed && result.percent == 110, + "trackpad wheel remainder produces one zoom step"); + expect_float(result.passthrough_y, 0.0f, + "modified vertical wheel is not passed through"); + + result = gui_ui_zoom_process(&state, result.percent, true, 0.0f, -1.0f); + expect_true(result.changed && result.percent == 100, + "negative modified wheel zooms out"); + + result = gui_ui_zoom_process(&state, 100, true, 0.0f, 3.0f); + expect_true(result.changed && result.percent == 130, + "large modified wheel delta can cross multiple steps"); + + result = gui_ui_zoom_process(&state, 200, true, 0.0f, 1.0f); + expect_true(result.consumed && result.step_attempted && + !result.changed && result.percent == 200, + "upper-bound wheel attempt remains visible to HUD routing"); + result = gui_ui_zoom_process(&state, 75, true, 0.0f, -1.0f); + expect_true(result.consumed && result.step_attempted && + !result.changed && result.percent == 75, + "lower-bound wheel attempt remains visible to HUD routing"); + result = gui_ui_zoom_process(&state, 75, true, 0.0f, 1.0f); + expect_true(result.changed && result.percent == 80, + "zooming in from 75 percent enters the 10-percent scale grid"); + + result = gui_ui_zoom_process(&state, 100, true, 0.75f, 0.0f); + expect_true(!result.consumed && !result.changed, + "horizontal-only modified wheel keeps existing behavior"); + expect_float(result.passthrough_x, 0.75f, + "horizontal-only modified wheel passes through"); + + state.wheel_remainder = 0.0f; + (void)gui_ui_zoom_process(&state, 100, true, 0.0f, 0.5f); + (void)gui_ui_zoom_process(&state, 100, true, 0.75f, 0.0f); + result = gui_ui_zoom_process(&state, 100, true, 0.0f, 0.5f); + expect_true(!result.step_attempted && !result.changed && result.percent == 100, + "horizontal modified wheel clears stale vertical remainder"); + + (void)gui_ui_zoom_process(&state, 100, true, 0.0f, 0.5f); + result = gui_ui_zoom_process(&state, 100, true, 1.0f, 0.05f); + expect_true(!result.consumed && !result.changed && result.percent == 100, + "horizontal-dominant diagonal gesture passes through"); + expect_float(result.passthrough_x, 1.0f, + "horizontal-dominant gesture preserves horizontal input"); + expect_float(result.passthrough_y, 0.05f, + "horizontal-dominant gesture preserves vertical noise"); + result = gui_ui_zoom_process(&state, 100, true, 0.0f, 0.5f); + expect_true(!result.changed && result.percent == 100, + "horizontal-dominant gesture clears prior zoom remainder"); + + state.wheel_remainder = 0.0f; + (void)gui_ui_zoom_process(&state, 100, true, 0.0f, 0.5f); + (void)gui_ui_zoom_process(&state, 100, false, 0.0f, 0.0f); + result = gui_ui_zoom_process(&state, 100, true, 0.0f, 0.5f); + expect_true(!result.changed && result.percent == 100, + "releasing the modifier clears a partial wheel gesture"); + + if (failures != 0) { + fprintf(stderr, "%d UI scale policy assertion(s) failed\n", failures); + return 1; + } + + puts("UI scale policy assertions passed"); + return 0; +}