From 2c74c0a0586ccab2a2a104ac8cd1c01fcc51ffa9 Mon Sep 17 00:00:00 2001 From: "jabber.developer2" Date: Sun, 10 May 2026 16:17:01 +0300 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20scroll=20mechanism=20=E2=80=94?= =?UTF-8?q?=20derive=20paged,=20defer=20render,=20split=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-level refactor of the chat-window scrolling state: L1. Extract win_page_space() and unify the two-branch clamp at the end of win_page_down into a single last_page_start formula. Removes the duplicated `getmaxy(stdscr) - 4` magic across page_up / page_down / sub_page_up / sub_page_down and the off-by-one between the two clamp branches. L2. Remove ProfLayout::paged sticky flag. Replace with win_is_paged(), derived from y_pos and the current pad height. Eliminates the entire bug class of "stuck paged=1" — there is no flag to drift, no manual reset to forget. The earlier non-CHAT bottom-reach reset, the WIN_SCROLL_REACHED_BOTTOM reset, the win_clear paged=1 line, and the cl_ev_send_ai_msg paged=0 workaround all collapse into "y_pos governs everything." cl_ev_send_ai_msg now calls win_move_to_end so the user's own message is visible after scroll-up. L3. _win_printf no longer drops messages when paged. The line is appended to the buffer and to the curses pad unconditionally; it lands below the visible viewport so the user's reading position is unchanged. The unread badge still increments. Suppressed messages are no longer lost forever — page-down naturally reveals them. L4. Replace the win_scroll_state_t enum (INNER / REACHED_TOP / REACHED_BOTTOM, conflating layout edge with DB exhaustion) with a ScrollEdges struct of two independent booleans: db_exhausted_above — a previous page-up DB fetch returned empty db_exhausted_below — same for page-down The flags are flipped to TRUE on DB_RESPONSE_EMPTY and cleared by a scroll in the opposite direction (so MAM late-delivery and /history switch can re-trigger fetches). Buffer-edge state is no longer encoded here — y_pos handles that. Tests: stub_ui gets a no-op win_move_to_end. 545/545 unit tests pass. --- src/event/client_events.c | 9 +-- src/ui/core.c | 2 +- src/ui/titlebar.c | 2 +- src/ui/win_types.h | 21 ++++-- src/ui/window.c | 139 +++++++++++++++++++---------------- src/ui/window.h | 2 + tests/unittests/ui/stub_ui.c | 5 ++ 7 files changed, 101 insertions(+), 79 deletions(-) diff --git a/src/event/client_events.c b/src/event/client_events.c index 6adfc612..33aab16a 100644 --- a/src/event/client_events.c +++ b/src/event/client_events.c @@ -301,11 +301,10 @@ cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const return; } - /* Reset paged flag before printing user message. - * If the user scrolled up to view history, paged=1 would suppress - * the message in _win_printf(). Reset it here so the message displays. */ - aiwin->window.layout->paged = 0; - aiwin->window.layout->unread_msg = 0; + // Snap viewport to bottom so the user's own message is visible even if + // they had scrolled up to read history; otherwise win_is_paged() in + // _win_printf would suppress the print. + win_move_to_end(&aiwin->window); // Display user message in AI window. win_print_outgoing(&aiwin->window, ">>", id, NULL, message); diff --git a/src/ui/core.c b/src/ui/core.c index be77756c..61356506 100644 --- a/src/ui/core.c +++ b/src/ui/core.c @@ -147,7 +147,7 @@ void ui_update(void) { ProfWin* current = wins_get_current(); - if (current->layout->paged == 0) { + if (!win_is_paged(current)) { win_move_to_end(current); } diff --git a/src/ui/titlebar.c b/src/ui/titlebar.c index bed87b4a..7af466a3 100644 --- a/src/ui/titlebar.c +++ b/src/ui/titlebar.c @@ -283,7 +283,7 @@ _show_attention(ProfWin* current, gboolean attention) static void _show_scrolled(ProfWin* current) { - if (current && current->layout->paged == 1) { + if (win_is_paged(current)) { int bracket_attrs = theme_attrs(THEME_TITLE_BRACKET); int scrolled_attrs = theme_attrs(THEME_TITLE_SCROLLED); diff --git a/src/ui/win_types.h b/src/ui/win_types.h index 2d4f80da..fbcc8d44 100644 --- a/src/ui/win_types.h +++ b/src/ui/win_types.h @@ -120,7 +120,6 @@ typedef struct prof_layout_t WINDOW* win; ProfBuff buffer; int y_pos; - int paged; int unread_msg; } ProfLayout; @@ -149,16 +148,24 @@ typedef enum { WIN_AI } win_type_t; -typedef enum { - WIN_SCROLL_INNER, - WIN_SCROLL_REACHED_TOP, - WIN_SCROLL_REACHED_BOTTOM -} win_scroll_state_t; +// ScrollEdges tracks the two end-of-history conditions independently, +// disentangling buffer-layout state (handled implicitly via y_pos) from +// DB-content state. db_exhausted_above means a previous page-up fetch came +// back empty: there is nothing older in the chat database, so the next +// page-up should not re-issue the SQL/flatfile read. db_exhausted_below +// is the analogous flag for newer rows. Both default to FALSE, are flipped +// to TRUE on a respective DB_RESPONSE_EMPTY fetch, and are cleared by +// scrolling in the opposite direction so a re-attempt happens after new +// activity (e.g. MAM late delivery, /history switch). +typedef struct { + gboolean db_exhausted_above; + gboolean db_exhausted_below; +} ScrollEdges; typedef struct prof_win_t { win_type_t type; - win_scroll_state_t scroll_state; + ScrollEdges scroll_edges; ProfLayout* layout; Autocomplete urls_ac; Autocomplete quotes_ac; diff --git a/src/ui/window.c b/src/ui/window.c index 94550f9a..f7fc127b 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -84,6 +84,26 @@ _check_subwin_width(int cols, int width) return cols <= 1 ? 1 : CLAMP(width, 1, cols - 1); } +// Visible chat-area height in lines: stdscr minus the title bar (1), the +// status bar (1), the input prompt area (1), and the separator (1). +int +win_page_space(void) +{ + return getmaxy(stdscr) - 4; +} + +// True when the user has scrolled away from the live tail and there is +// content below the visible viewport. Derived from y_pos and the current +// height of the pad — no sticky flag, no possibility of staleness. +gboolean +win_is_paged(const ProfWin* window) +{ + if (!window || !window->layout || !window->layout->win) + return FALSE; + int total_rows = getcury(window->layout->win); + return (total_rows - window->layout->y_pos) > win_page_space(); +} + int win_roster_cols(void) { @@ -117,7 +137,6 @@ _win_create_simple_layout(void) wbkgd(layout->base.win, theme_attrs(THEME_TEXT)); layout->base.buffer = buffer_create(); layout->base.y_pos = 0; - layout->base.paged = 0; layout->base.unread_msg = 0; scrollok(layout->base.win, TRUE); @@ -135,7 +154,6 @@ _win_create_split_layout(void) wbkgd(layout->base.win, theme_attrs(THEME_TEXT)); layout->base.buffer = buffer_create(); layout->base.y_pos = 0; - layout->base.paged = 0; layout->base.unread_msg = 0; scrollok(layout->base.win, TRUE); layout->subwin = NULL; @@ -150,7 +168,7 @@ win_create_console(void) { ProfConsoleWin* new_win = malloc(sizeof(ProfConsoleWin)); new_win->window.type = WIN_CONSOLE; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_split_layout(); return &new_win->window; @@ -162,7 +180,7 @@ win_create_chat(const char* const barejid) assert(barejid != NULL); ProfChatWin* new_win = malloc(sizeof(ProfChatWin)); new_win->window.type = WIN_CHAT; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_simple_layout(); new_win->barejid = strdup(barejid); @@ -196,7 +214,7 @@ win_create_muc(const char* const roomjid) int cols = getmaxx(stdscr); new_win->window.type = WIN_MUC; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; ProfLayoutSplit* layout = malloc(sizeof(ProfLayoutSplit)); layout->base.type = LAYOUT_SPLIT; @@ -215,7 +233,6 @@ win_create_muc(const char* const roomjid) layout->memcheck = LAYOUT_SPLIT_MEMCHECK; layout->base.buffer = buffer_create(); layout->base.y_pos = 0; - layout->base.paged = 0; layout->base.unread_msg = 0; scrollok(layout->base.win, TRUE); new_win->window.layout = (ProfLayout*)layout; @@ -254,7 +271,7 @@ win_create_config(const char* const roomjid, DataForm* form, ProfConfWinCallback assert(form != NULL); ProfConfWin* new_win = malloc(sizeof(ProfConfWin)); new_win->window.type = WIN_CONFIG; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_simple_layout(); new_win->roomjid = strdup(roomjid); new_win->form = form; @@ -273,7 +290,7 @@ win_create_private(const char* const fulljid) assert(fulljid != NULL); ProfPrivateWin* new_win = malloc(sizeof(ProfPrivateWin)); new_win->window.type = WIN_PRIVATE; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_simple_layout(); new_win->fulljid = strdup(fulljid); new_win->unread = 0; @@ -290,7 +307,7 @@ win_create_xmlconsole(void) { ProfXMLWin* new_win = malloc(sizeof(ProfXMLWin)); new_win->window.type = WIN_XML; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_simple_layout(); new_win->memcheck = PROFXMLWIN_MEMCHECK; @@ -305,7 +322,7 @@ win_create_plugin(const char* const plugin_name, const char* const tag) assert(tag != NULL); ProfPluginWin* new_win = malloc(sizeof(ProfPluginWin)); new_win->window.type = WIN_PLUGIN; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_simple_layout(); new_win->tag = strdup(tag); @@ -322,7 +339,7 @@ win_create_vcard(vCard* vcard) assert(vcard != NULL); ProfVcardWin* new_win = malloc(sizeof(ProfVcardWin)); new_win->window.type = WIN_VCARD; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_simple_layout(); new_win->vcard = vcard; @@ -336,7 +353,7 @@ win_create_ai(AISession* session) { ProfAiWin* new_win = g_new0(ProfAiWin, 1); new_win->window.type = WIN_AI; - new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE }; new_win->window.layout = _win_create_simple_layout(); new_win->message_buffer = buffer_create(); @@ -722,11 +739,13 @@ win_page_up(ProfWin* window, int scroll_size) int* page_start = &(window->layout->y_pos); int page_start_initial = *page_start; // Size of the visible chat page - int page_space = getmaxy(stdscr) - 4; + int page_space = win_page_space(); if (scroll_size == 0) scroll_size = page_space; - win_scroll_state_t* scroll_state = &window->scroll_state; - *scroll_state = (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) ? WIN_SCROLL_INNER : *scroll_state; + ScrollEdges* edges = &window->scroll_edges; + // Going up means the bottom of the DB may have new content again; allow + // a re-fetch on the next page-down. + edges->db_exhausted_below = FALSE; *page_start -= scroll_size; gboolean has_scroll_past_buffer = *page_start < 0; @@ -736,16 +755,18 @@ win_page_up(ProfWin* window, int scroll_size) gboolean is_still_fetching_mam = first_entry && (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0); if (!is_still_fetching_mam) { - // WIN_SCROLL_REACHED_TOP means that we reached top of DB and there is no need to refetch data from it - gboolean is_db_offset_jump_needed = false; - if (*scroll_state != WIN_SCROLL_REACHED_TOP) { + // db_exhausted_above suppresses further upward DB fetches; once + // a previous fetch returned empty, there is nothing older. + gboolean is_db_offset_jump_needed = FALSE; + if (!edges->db_exhausted_above) { db_history_result_t db_response = chatwin_db_history(chatwin, NULL, NULL, TRUE); is_db_offset_jump_needed = db_response == DB_RESPONSE_SUCCESS; - *scroll_state = db_response == DB_RESPONSE_EMPTY ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER; - log_debug("Scroll state after DB history fetch: %s", *scroll_state ? "WIN_SCROLL_REACHED_TOP" : "WIN_SCROLL_INNER"); + edges->db_exhausted_above = (db_response == DB_RESPONSE_EMPTY); + log_debug("Scroll edges after DB history fetch: above=%d below=%d", + edges->db_exhausted_above, edges->db_exhausted_below); } - if (*scroll_state == WIN_SCROLL_REACHED_TOP) { + if (edges->db_exhausted_above) { *page_start = 0; if (prefs_get_boolean(PREF_MAM)) { win_print_loading_history(window); @@ -777,9 +798,7 @@ win_page_up(ProfWin* window, int scroll_size) win_update_virtual(window); } - // Update scrolling state - int total_rows = getcury(window->layout->win); - window->layout->paged = (total_rows) - *page_start > page_space; + // paged is derived on demand from y_pos / total_rows; nothing to set here. } void @@ -788,13 +807,15 @@ win_page_down(ProfWin* window, int scroll_size) int total_rows = getcury(window->layout->win); int total_rows_with_unread = total_rows + window->layout->unread_msg; int* page_start = &(window->layout->y_pos); - int page_space = getmaxy(stdscr) - 4; + int page_space = win_page_space(); int page_start_initial = *page_start; if (scroll_size == 0) scroll_size = page_space; - win_scroll_state_t* scroll_state = &window->scroll_state; - *scroll_state = (*scroll_state == WIN_SCROLL_REACHED_TOP) ? WIN_SCROLL_INNER : *scroll_state; + ScrollEdges* edges = &window->scroll_edges; + // Going down means the top of the DB may have new content again; allow + // a re-fetch on the next page-up (e.g. MAM late delivery). + edges->db_exhausted_above = FALSE; *page_start += scroll_size; @@ -805,7 +826,7 @@ win_page_down(ProfWin* window, int scroll_size) if ((past_bottom || at_page_space_and_past_unread) && is_chat) { int bf_size = buffer_size(window->layout->buffer); - if (bf_size > 0 && *scroll_state != WIN_SCROLL_REACHED_BOTTOM) { + if (bf_size > 0 && !edges->db_exhausted_below) { // How many lines are left until end of the screen int current_offset = total_rows - *page_start; GDateTime* now = g_date_time_new_now_local(); @@ -814,7 +835,7 @@ win_page_down(ProfWin* window, int scroll_size) auto_gchar gchar* end_date = g_date_time_format_iso8601(now); db_history_result_t db_response = chatwin_db_history((ProfChatWin*)window, start, end_date, FALSE); if (db_response == DB_RESPONSE_EMPTY) - *scroll_state = WIN_SCROLL_REACHED_BOTTOM; + edges->db_exhausted_below = TRUE; // similar to page_up (see explanation there) // Get offset of the message that was the latest prior to loading messages from the DB @@ -826,34 +847,22 @@ win_page_down(ProfWin* window, int scroll_size) total_rows = getcury(window->layout->win); - // near the end, but will display only part of the page space, move a bit back to show full page - if ((total_rows - *page_start) < page_space) { - *page_start = total_rows - page_space; - // went past end, show last page - } else if (*page_start >= total_rows) { - *page_start = total_rows - page_space - 1; + // Clamp to the last full page so the viewport never goes past the buffer + // and always shows a full page when one is available. + int last_page_start = total_rows > page_space ? total_rows - page_space : 0; + if (*page_start > last_page_start) { + *page_start = last_page_start; } - window->layout->paged = 1; - // update only if position has changed if ((page_start_initial != *page_start) || window->layout->unread_msg) { win_update_virtual(window); } - /* Switch off page if no messages left to read. - * TODO: update buffer end handling to check messages just after last entry. - */ - if (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) { - window->layout->paged = 0; - window->layout->unread_msg = 0; - } - - // Non-chat windows have no DB to fetch from, so WIN_SCROLL_REACHED_BOTTOM - // never fires; reset paged once the buffer's last line is on screen. - if (window->type != WIN_CHAT - && (total_rows - *page_start) <= page_space + 1) { - window->layout->paged = 0; + // Once the user is back at the buffer's last page the unread badge no + // longer reflects anything they have not seen — clear it. paged itself + // is derived from y_pos so there is no flag to flip. + if (*page_start >= last_page_start) { window->layout->unread_msg = 0; } } @@ -862,8 +871,7 @@ void win_sub_page_down(ProfWin* window) { if (window->layout->type == LAYOUT_SPLIT) { - int rows = getmaxy(stdscr); - int page_space = rows - 4; + int page_space = win_page_space(); ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout; int sub_y = getcury(split_layout->subwin); int* sub_y_pos = &(split_layout->sub_y_pos); @@ -886,8 +894,7 @@ void win_sub_page_up(ProfWin* window) { if (window->layout->type == LAYOUT_SPLIT) { - int rows = getmaxy(stdscr); - int page_space = rows - 4; + int page_space = win_page_space(); ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout; int* sub_y_pos = &(split_layout->sub_y_pos); @@ -914,7 +921,6 @@ win_clear(ProfWin* window) int y = getcury(window->layout->win); int* page_start = &(window->layout->y_pos); *page_start = y; - window->layout->paged = 1; window->layout->unread_msg = 0; win_update_virtual(window); } @@ -1024,14 +1030,12 @@ win_refresh_with_subwin(ProfWin* window) void win_move_to_end(ProfWin* window) { - window->layout->paged = 0; window->layout->unread_msg = 0; - int rows = getmaxy(stdscr); int y = getcury(window->layout->win); - int size = rows - 3; + int page_space = win_page_space(); - window->layout->y_pos = y - (size - 1); + window->layout->y_pos = y - page_space; if (window->layout->y_pos < 0) { window->layout->y_pos = 0; } @@ -1808,12 +1812,13 @@ win_newline(ProfWin* window) static void _win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* timestamp, int flags, theme_item_t theme_item, const char* const display_from, const char* const from_jid, const char* const message_id, const char* const message, ...) { - - /* Prevent printing and buffer update when user is viewing message history [SCROLLING]*/ - if (window->layout->paged && wins_is_current(window)) { - window->layout->unread_msg++; - return; - } + /* Append the message to the curses pad and the in-memory buffer + * unconditionally. When the user is scrolled up (paged), the new line + * lands on rows below the visible viewport so it does not disturb what + * they are reading; they encounter it naturally on page-down. The + * unread badge is bumped so the title bar still reflects new activity. + */ + const gboolean is_paged_now = win_is_paged(window) && wins_is_current(window); if (timestamp == NULL) { timestamp = g_date_time_new_now_local(); @@ -1830,6 +1835,10 @@ _win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* t _win_print_internal(window, show_char, pad_indent, timestamp, flags, theme_item, display_from, msg, NULL); buffer_append(window->layout->buffer, show_char, pad_indent, timestamp, flags, theme_item, display_from, from_jid, msg, NULL, message_id, y_start_pos, getcury(window->layout->win)); + if (is_paged_now) { + window->layout->unread_msg++; + } + inp_nonblocking(TRUE); g_date_time_unref(timestamp); diff --git a/src/ui/window.h b/src/ui/window.h index 2c4da707..7c4c2082 100644 --- a/src/ui/window.h +++ b/src/ui/window.h @@ -81,6 +81,8 @@ void win_redraw(ProfWin* window); void win_print_loading_history(ProfWin* window); int win_roster_cols(void); int win_occpuants_cols(void); +int win_page_space(void); +gboolean win_is_paged(const ProfWin* window); void win_sub_print(WINDOW* win, char* msg, gboolean newline, gboolean wrap, int indent); void win_sub_newline_lazy(WINDOW* win); void win_mark_received(ProfWin* window, const char* const id); diff --git a/tests/unittests/ui/stub_ui.c b/tests/unittests/ui/stub_ui.c index d15177d6..39f3dfdb 100644 --- a/tests/unittests/ui/stub_ui.c +++ b/tests/unittests/ui/stub_ui.c @@ -1433,3 +1433,8 @@ cons_has_alerts(void) { return FALSE; } + +void +win_move_to_end(ProfWin* window) +{ +}