/** * @file app_shell.c * @brief Pure state machinery shared by the Notcurses application shell. */ #include "trainlog/app_shell.h" #include #include #include #include "trainlog/terminal.h" #include "trainlog/presentation.h" static void copy_id(char output[TRAINLOG_SHELL_STABLE_ID_CAPACITY], const char *value) { (void)snprintf(output, TRAINLOG_SHELL_STABLE_ID_CAPACITY, "%s", value != NULL ? value : ""); } void trainlog_shell_layout_compute(int columns, int rows, TrainlogShellLayout *layout) { int content_x; if (layout == NULL) return; (void)memset(layout, 0, sizeof(*layout)); layout->rows = rows; layout->columns = columns; layout->usable = columns >= TRAINLOG_SHELL_MIN_COLUMNS && rows >= TRAINLOG_SHELL_MIN_ROWS; if (!layout->usable) return; /* CONTRACT: chrome owns four rows on every valid geometry. Content and * overlays can therefore never clip or overwrite the footer. */ layout->header = (TrainlogRect){0, 0, 2, columns}; layout->footer = (TrainlogRect){rows - 2, 0, 2, columns}; layout->sidebar_visible = columns >= 100 && rows >= 26; layout->sidebar_expanded = columns >= 120 && rows >= 32; content_x = layout->sidebar_visible ? 23 : 0; if (layout->sidebar_visible) { layout->sidebar = (TrainlogRect){2, 0, rows - 4, TRAINLOG_SHELL_SIDEBAR_COLUMNS}; layout->separator = (TrainlogRect){2, 22, rows - 4, 1}; } layout->content = (TrainlogRect){2, content_x, rows - 4, columns - content_x}; } TrainlogRect trainlog_shell_overlay_rect(const TrainlogShellLayout *layout, int preferred_width, int preferred_height) { TrainlogRect result = {0, 0, 0, 0}; int maximum_width; int maximum_height; if (layout == NULL || !layout->usable) return result; maximum_width = layout->columns >= 100 ? 80 : layout->columns; if (layout->columns == 100 && maximum_width > 74) maximum_width = 74; if (layout->columns == 80 && maximum_width > 78) maximum_width = 78; maximum_height = layout->content.height; if (layout->columns >= 120 && maximum_height > 29) maximum_height = 29; if (layout->columns == 100 && maximum_height > 24) maximum_height = 24; result.width = preferred_width > 0 && preferred_width < maximum_width ? preferred_width : maximum_width; result.height = preferred_height > 0 && preferred_height < maximum_height ? preferred_height : maximum_height; result.x = (layout->columns - result.width) / 2; result.y = layout->content.y + (layout->content.height - result.height) / 2; return result; } bool trainlog_shell_rect_contains(const TrainlogRect *outer, const TrainlogRect *inner) { return outer != NULL && inner != NULL && inner->height >= 0 && inner->width >= 0 && inner->y >= outer->y && inner->x >= outer->x && inner->y + inner->height <= outer->y + outer->height && inner->x + inner->width <= outer->x + outer->width; } void trainlog_navigation_init(TrainlogNavigationState *navigation) { if (navigation == NULL) return; (void)memset(navigation, 0, sizeof(*navigation)); navigation->current.route = TRAINLOG_ROUTE_HOME; } bool trainlog_navigation_open(TrainlogNavigationState *navigation, TrainlogAppRoute route, const char *stable_id) { if (navigation == NULL) return false; if (navigation->current.route == route && strcmp(navigation->current.stable_id, stable_id != NULL ? stable_id : "") == 0) return true; if (navigation->back_count == TRAINLOG_SHELL_ROUTE_DEPTH) { (void)memmove(&navigation->back[0], &navigation->back[1], (TRAINLOG_SHELL_ROUTE_DEPTH - 1U) * sizeof(navigation->back[0])); --navigation->back_count; } navigation->back[navigation->back_count++] = navigation->current; navigation->current.route = route; copy_id(navigation->current.stable_id, stable_id); return true; } bool trainlog_navigation_back(TrainlogNavigationState *navigation) { if (navigation == NULL || navigation->back_count == 0U) return false; navigation->current = navigation->back[--navigation->back_count]; return true; } void trainlog_actions_clear(TrainlogActionModel *model) { if (model != NULL) (void)memset(model, 0, sizeof(*model)); } bool trainlog_actions_add(TrainlogActionModel *model, const TrainlogAction *action) { size_t index; if (model == NULL || action == NULL || action->identifier == NULL || action->label == NULL || model->count >= TRAINLOG_SHELL_ACTION_LIMIT) return false; /* INVARIANT: identifier is the action's stable identity. Keeping it unique * prevents the footer and F7 registry from advertising the same semantic * action twice when contextual and shell-wide contributors overlap. */ for (index = 0U; index < model->count; ++index) if (strcmp(model->items[index].identifier, action->identifier) == 0) return false; model->items[model->count++] = *action; return true; } const TrainlogAction *trainlog_actions_find_key(const TrainlogActionModel *model, int key) { size_t index; if (model == NULL) return NULL; for (index = 0U; index < model->count; ++index) { if (model->items[index].enabled && model->items[index].key == key) return &model->items[index]; } return NULL; } const TrainlogAction *trainlog_actions_at_priority(const TrainlogActionModel *model, size_t position) { const TrainlogAction *result = NULL; size_t rank; size_t index; if (model == NULL) return NULL; for (rank = 0U; rank <= position; ++rank) { result = NULL; for (index = 0U; index < model->count; ++index) { const TrainlogAction *candidate = &model->items[index]; size_t prior = 0U; size_t other; if (!candidate->enabled) continue; for (other = 0U; other < model->count; ++other) { if (!model->items[other].enabled) continue; if (model->items[other].priority < candidate->priority || (model->items[other].priority == candidate->priority && other < index)) ++prior; } if (prior == rank) { result = candidate; break; } } if (result == NULL || rank == position) break; } return result; } void trainlog_overlays_init(TrainlogOverlayStack *stack) { if (stack != NULL) (void)memset(stack, 0, sizeof(*stack)); } bool trainlog_overlays_push(TrainlogOverlayStack *stack, TrainlogOverlayType type, TrainlogFocusTarget restore_focus, const char *restore_stable_id) { TrainlogOverlay *overlay; if (stack == NULL || stack->count >= TRAINLOG_SHELL_OVERLAY_LIMIT) return false; overlay = &stack->items[stack->count++]; (void)memset(overlay, 0, sizeof(*overlay)); overlay->type = type; overlay->restore_focus = restore_focus; copy_id(overlay->restore_stable_id, restore_stable_id); return true; } const TrainlogOverlay *trainlog_overlays_top(const TrainlogOverlayStack *stack) { return stack != NULL && stack->count > 0U ? &stack->items[stack->count - 1U] : NULL; } bool trainlog_overlays_pop(TrainlogOverlayStack *stack, TrainlogFocusTarget *restore_focus, char restore_stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]) { TrainlogOverlay *overlay; if (stack == NULL || stack->count == 0U) return false; overlay = &stack->items[--stack->count]; if (restore_focus != NULL) *restore_focus = overlay->restore_focus; if (restore_stable_id != NULL) copy_id(restore_stable_id, overlay->restore_stable_id); (void)memset(overlay, 0, sizeof(*overlay)); return true; } void trainlog_list_init(TrainlogListState *list) { if (list != NULL) (void)memset(list, 0, sizeof(*list)); } void trainlog_list_set_items(TrainlogListState *list, const char *const *stable_ids, size_t count, size_t visible_rows, bool total_known, bool more_available) { size_t index; size_t selected = 0U; bool found = false; if (list == NULL) return; for (index = 0U; index < count && stable_ids != NULL; ++index) { if (strcmp(list->selected_id, stable_ids[index]) == 0) { selected = index; found = true; break; } } if (!found && count > 0U) { selected = list->selected_index < count ? list->selected_index : count - 1U; copy_id(list->selected_id, stable_ids[selected]); } else if (count == 0U) { list->selected_id[0] = '\0'; selected = 0U; } list->selected_index = selected; list->item_count = count; list->visible_rows = visible_rows > 0U ? visible_rows : 1U; list->total_known = total_known; list->more_available = more_available; if (selected < list->viewport_start) list->viewport_start = selected; if (selected >= list->viewport_start + list->visible_rows) list->viewport_start = selected - list->visible_rows + 1U; if (count <= list->visible_rows) list->viewport_start = 0U; else if (list->viewport_start > count - list->visible_rows) list->viewport_start = count - list->visible_rows; } void trainlog_list_move(TrainlogListState *list, const char *const *stable_ids, int delta) { size_t next; if (list == NULL || stable_ids == NULL || list->item_count == 0U) return; if (delta < 0) { size_t amount = (size_t)(-(long long)delta); next = amount > list->selected_index ? 0U : list->selected_index - amount; } else { size_t amount = (size_t)delta; next = amount > list->item_count - 1U - list->selected_index ? list->item_count - 1U : list->selected_index + amount; } list->selected_index = next; copy_id(list->selected_id, stable_ids[next]); if (next < list->viewport_start) list->viewport_start = next; if (next >= list->viewport_start + list->visible_rows) list->viewport_start = next - list->visible_rows + 1U; } void trainlog_shell_format_list_label(const char *value, int maximum_cells, char *output, size_t output_capacity) { const char *input = value != NULL ? value : ""; size_t input_bytes = 0U; size_t output_bytes = 0U; int cells = 0; bool truncated = false; if (output == NULL || output_capacity == 0U) return; output[0] = '\0'; if (maximum_cells <= 0) return; /* INVARIANT: reserve one display cell for the truncation marker before * copying a code point. This keeps labels valid UTF-8 and makes clipped * stable-ID-backed rows visibly distinct from complete names. */ while (input[input_bytes] != '\0') { utf8proc_int32_t codepoint; utf8proc_ssize_t parsed = utf8proc_iterate( (const utf8proc_uint8_t *)input + input_bytes, -1, &codepoint); int width; if (parsed <= 0) { parsed = 1; codepoint = (unsigned char)input[input_bytes]; } width = utf8proc_charwidth(codepoint); if (width < 0) width = 1; if (cells + width > maximum_cells || output_bytes + (size_t)parsed >= output_capacity) { truncated = true; break; } (void)memcpy(output + output_bytes, input + input_bytes, (size_t)parsed); output_bytes += (size_t)parsed; input_bytes += (size_t)parsed; cells += width; } if (input[input_bytes] != '\0') truncated = true; if (truncated) { static const char ellipsis[] = "…"; while (output_bytes > 0U && cells >= maximum_cells) { utf8proc_int32_t codepoint; size_t start = output_bytes - 1U; while (start > 0U && ((unsigned char)output[start] & 0xc0U) == 0x80U) --start; if (utf8proc_iterate((const utf8proc_uint8_t *)output + start, (utf8proc_ssize_t)(output_bytes - start), &codepoint) > 0) { int width = utf8proc_charwidth(codepoint); cells -= width > 0 ? width : 1; } output_bytes = start; } if (output_bytes + sizeof(ellipsis) <= output_capacity) { (void)memcpy(output + output_bytes, ellipsis, sizeof(ellipsis)); return; } } output[output_bytes] = '\0'; } void trainlog_search_init(TrainlogSearchState *search) { if (search == NULL) return; (void)memset(search, 0, sizeof(*search)); } bool trainlog_search_insert(TrainlogSearchState *search, const char *utf8, size_t length) { utf8proc_int32_t codepoint; utf8proc_ssize_t parsed; if (search == NULL || utf8 == NULL || length == 0U || search->bytes + length >= TRAINLOG_SHELL_SEARCH_CAPACITY) { if (search != NULL) search->capacity_error = true; return false; } parsed = utf8proc_iterate((const utf8proc_uint8_t *)utf8, (utf8proc_ssize_t)length, &codepoint); if (parsed <= 0 || (size_t)parsed != length || codepoint < 0x20 || codepoint == 0x7f) return false; (void)memmove(search->text + search->cursor + length, search->text + search->cursor, search->bytes - search->cursor + 1U); (void)memcpy(search->text + search->cursor, utf8, length); search->cursor += length; search->bytes += length; search->capacity_error = false; return true; } static size_t next_grapheme_boundary(const char *text, size_t bytes, size_t start) { utf8proc_int32_t previous; utf8proc_ssize_t parsed; size_t cursor = start; utf8proc_int32_t state = 0; if (text == NULL || start >= bytes) return bytes; parsed = utf8proc_iterate((const utf8proc_uint8_t *)text + cursor, (utf8proc_ssize_t)(bytes - cursor), &previous); if (parsed <= 0) return start + 1U; cursor += (size_t)parsed; while (cursor < bytes) { utf8proc_int32_t current; parsed = utf8proc_iterate((const utf8proc_uint8_t *)text + cursor, (utf8proc_ssize_t)(bytes - cursor), ¤t); if (parsed <= 0 || utf8proc_grapheme_break_stateful(previous, current, &state) != 0) break; previous = current; cursor += (size_t)parsed; } return cursor; } static size_t previous_grapheme_boundary(const char *text, size_t bytes, size_t cursor) { size_t previous = 0U; size_t next = 0U; while (next < cursor && next < bytes) { previous = next; next = next_grapheme_boundary(text, bytes, next); } return previous; } bool trainlog_search_backspace(TrainlogSearchState *search) { size_t start; if (search == NULL || search->cursor == 0U) return false; start = previous_grapheme_boundary(search->text, search->bytes, search->cursor); (void)memmove(search->text + start, search->text + search->cursor, search->bytes - search->cursor + 1U); search->bytes -= search->cursor - start; search->cursor = start; search->capacity_error = false; return true; } void trainlog_search_home(TrainlogSearchState *search) { if (search != NULL) search->cursor = 0U; } void trainlog_search_end(TrainlogSearchState *search) { if (search != NULL) search->cursor = search->bytes; } void trainlog_search_left(TrainlogSearchState *search) { if (search == NULL || search->cursor == 0U) return; search->cursor = previous_grapheme_boundary(search->text, search->bytes, search->cursor); } void trainlog_search_right(TrainlogSearchState *search) { if (search == NULL || search->cursor >= search->bytes) return; search->cursor = next_grapheme_boundary(search->text, search->bytes, search->cursor); } bool trainlog_search_escape(TrainlogSearchState *search) { if (search == NULL) return false; if (search->bytes > 0U) { search->text[0] = '\0'; search->bytes = 0U; search->cursor = 0U; search->capacity_error = false; return false; } search->open = false; search->focused = false; return true; } void trainlog_form_init(TrainlogFormField *field, const char *initial_value) { size_t length; if (field == NULL) return; (void)memset(field, 0, sizeof(*field)); length = initial_value != NULL ? strlen(initial_value) : 0U; if (length > TRAINLOG_SHELL_SEARCH_CAPACITY - 1U) length = TRAINLOG_SHELL_SEARCH_CAPACITY - 1U; if (length > 0U) (void)memcpy(field->text, initial_value, length); field->text[length] = '\0'; field->bytes = length; field->cursor = length; field->active = true; } TrainlogFormResult trainlog_form_handle(TrainlogFormField *field, int key) { TrainlogSearchState editor; bool changed = false; if (field == NULL || !field->active) return TRAINLOG_FORM_IGNORED; if (key == TRAINLOG_KEY_ENTER || key == '\n') return TRAINLOG_FORM_SUBMIT; if (key == TRAINLOG_KEY_ESCAPE || key == 27) return TRAINLOG_FORM_CANCEL; if (key == TRAINLOG_KEY_TAB) return TRAINLOG_FORM_NEXT; if (key == TRAINLOG_KEY_SHIFT_TAB) return TRAINLOG_FORM_PREVIOUS; if (key == TRAINLOG_KEY_F6) return TRAINLOG_FORM_OPEN_NAVIGATION; if (key == TRAINLOG_KEY_F7) return TRAINLOG_FORM_OPEN_ACTIONS; (void)memset(&editor, 0, sizeof(editor)); (void)snprintf(editor.text, sizeof(editor.text), "%s", field->text); editor.bytes = field->bytes; editor.cursor = field->cursor; editor.open = true; editor.focused = true; if (key == TRAINLOG_KEY_HOME) trainlog_search_home(&editor); else if (key == TRAINLOG_KEY_END) trainlog_search_end(&editor); else if (key == TRAINLOG_KEY_LEFT) trainlog_search_left(&editor); else if (key == TRAINLOG_KEY_RIGHT) trainlog_search_right(&editor); else if (key == TRAINLOG_KEY_BACKSPACE || key == TRAINLOG_KEY_DELETE) changed = trainlog_search_backspace(&editor); else if (key >= 0x20 && key <= 0x10ffff) { char encoded[5] = ""; utf8proc_ssize_t bytes = utf8proc_encode_char((utf8proc_int32_t)key, (utf8proc_uint8_t *)encoded); if (bytes > 0) changed = trainlog_search_insert(&editor, encoded, (size_t)bytes); } else return TRAINLOG_FORM_IGNORED; (void)snprintf(field->text, sizeof(field->text), "%s", editor.text); field->bytes = editor.bytes; field->cursor = editor.cursor; field->capacity_error = editor.capacity_error; return changed ? TRAINLOG_FORM_EDITED : TRAINLOG_FORM_IGNORED; } const char *trainlog_route_title(TrainlogAppRoute route) { static const char *const keys[TRAINLOG_ROUTE_COUNT] = { "nav.home", "nav.sessions", "route.session.current", "route.session.generator", "route.session.manual", "route.session.completed", "route.session.detail", "route.exercise.catalog", "route.exercise.detail", "route.exercise.knowledge", "route.exercise.performance", "route.exercise.max", "route.equipment.catalog", "route.equipment.detail", "nav.statistics", "route.stats.training", "route.stats.exercise", "route.stats.exercise.detail", "route.stats.zones", "route.body", "route.body.detail", "route.body.metric", "route.body.trends", "route.body.global", "route.body.analytics", "route.max", "nav.sync", "nav.settings" }; /* INVARIANT: enum order is the stable route identity; wording is resolved * only at this presentation boundary. */ if (route >= TRAINLOG_ROUTE_HOME && route < TRAINLOG_ROUTE_COUNT) return trainlog_presentation_text(keys[route]); return "Trainlog"; } TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route) { if (route >= TRAINLOG_ROUTE_SESSIONS && route <= TRAINLOG_ROUTE_SESSION_DETAIL) return TRAINLOG_ROUTE_SESSIONS; if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL || route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) return TRAINLOG_ROUTE_EXERCISES; if (route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE || route == TRAINLOG_ROUTE_EXERCISE_MAX) return TRAINLOG_ROUTE_STATS; if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) return TRAINLOG_ROUTE_EQUIPMENT; if (route == TRAINLOG_ROUTE_STATS_TRAINING || route == TRAINLOG_ROUTE_STATS_EXERCISE || route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL || route == TRAINLOG_ROUTE_STATS_ZONES || (route >= TRAINLOG_ROUTE_BODY && route <= TRAINLOG_ROUTE_BODY_ANALYTICS) || route == TRAINLOG_ROUTE_MAX) return TRAINLOG_ROUTE_STATS; return route; } TrainlogLeaveDecision trainlog_shell_leave_decision( const TrainlogDurabilityState *state, bool explicit_discard) { if (state == NULL) return TRAINLOG_LEAVE_ALLOW; if (explicit_discard) return state->session_draft || state->session_dirty || state->generator_configuration_dirty || state->generator_preview || state->generator_preview_dirty || state->transient_form_dirty ? TRAINLOG_LEAVE_CONFIRM_DISCARD : TRAINLOG_LEAVE_ALLOW; if (state->generator_preview || state->generator_preview_dirty || state->generator_configuration_dirty || state->transient_form_dirty) return TRAINLOG_LEAVE_CONFIRM_KEEP; /* A session draft is owned in memory by the run and ordinary navigation * suspends it. Only q/discard asks to erase it. */ return TRAINLOG_LEAVE_ALLOW; } void trainlog_shell_discard_transient(TrainlogDurabilityState *state) { if (state != NULL) (void)memset(state, 0, sizeof(*state)); }