diff --git a/docs/uiux/components/dialog_frame.md b/docs/uiux/components/dialog_frame.md index 2b8a29e5..04154c9b 100644 --- a/docs/uiux/components/dialog_frame.md +++ b/docs/uiux/components/dialog_frame.md @@ -11,8 +11,17 @@ and an optional callback context. The returned `Frame` is a view of LVGL widgets, not a retained controller. Pages add their content to `frame.body`, put `frame.close` in their existing focus group, and delete `frame.overlay` when closing. They retain responsibility for restoring focus and stopping -their own timers or tasks. The close button supports touch and Enter; page -key callbacks retain their existing Escape/Back behavior. +their own timers or tasks. The close button supports touch and Enter. After +building the focus group, pages call `bind_close_shortcuts(frame, group)` so +X/x closes from any of this dialog's controls. The shared handler runs before +page key handlers. Page callbacks retain their existing Escape/Back behavior. +Controls in a separate nested dialog do not inherit the parent shortcut. + +Short action lists reuse the flat popup-row style and call +`fit_content_height(frame)` after adding rows. This preserves the frame's +padding and removes unused vertical space while retaining viewport bounds +and scrolling. Closing is available in the title bar without a duplicate +Cancel row. This uses composition rather than a hierarchy of dialog subclasses. There is no global modal registry, copied business model, persistent dialog cache or diff --git a/modules/ui_shared/include/ui/components/dialog_frame.h b/modules/ui_shared/include/ui/components/dialog_frame.h index 763be238..522b298f 100644 --- a/modules/ui_shared/include/ui/components/dialog_frame.h +++ b/modules/ui_shared/include/ui/components/dialog_frame.h @@ -25,4 +25,8 @@ struct Frame // The page owns the returned widgets and focus group. No retained controller or // business data: the fixed title bar and scrolling body are just shared chrome. Frame create(lv_obj_t* parent, const Config& config); +// Call after populating the body for a compact, content-sized action dialog. +void fit_content_height(const Frame& frame); +// Register X/x on this dialog's controls after the page builds its focus group. +void bind_close_shortcuts(const Frame& frame, lv_group_t* group); } // namespace ui::components::dialog_frame diff --git a/modules/ui_shared/include/ui/widgets/top_bar.h b/modules/ui_shared/include/ui/widgets/top_bar.h index ef7446a4..a2ad5dfd 100644 --- a/modules/ui_shared/include/ui/widgets/top_bar.h +++ b/modules/ui_shared/include/ui/widgets/top_bar.h @@ -60,10 +60,5 @@ void top_bar_set_right_text_ascii(TopBar& bar, const char* text); */ void top_bar_set_back_callback(TopBar& bar, void (*cb)(void*), void* user_data); -// Same title-bar metrics and theme as a page, with a compact right-side close. -lv_obj_t* top_bar_create_dialog_header(lv_obj_t* parent, const char* title, - lv_event_cb_t on_close, void* context = nullptr, - lv_obj_t** close_button = nullptr); - } // namespace widgets } // namespace ui diff --git a/modules/ui_shared/src/ui/components/dialog_frame.cpp b/modules/ui_shared/src/ui/components/dialog_frame.cpp index 55ca715d..7d312f49 100644 --- a/modules/ui_shared/src/ui/components/dialog_frame.cpp +++ b/modules/ui_shared/src/ui/components/dialog_frame.cpp @@ -1,5 +1,7 @@ #include "ui/components/dialog_frame.h" +#include "ui/components/bottom_bar.h" +#include "ui/localization.h" #include "ui/widgets/top_bar.h" #include @@ -9,10 +11,12 @@ namespace { void close_key(lv_event_t* event) { - if (lv_event_get_key(event) != LV_KEY_ENTER) return; + const auto key = lv_event_get_key(event); + auto* close = static_cast(lv_event_get_user_data(event)); + if (key != 'x' && key != 'X' && !(key == LV_KEY_ENTER && lv_event_get_target(event) == close)) return; lv_event_stop_bubbling(event); lv_event_stop_processing(event); - lv_obj_send_event(static_cast(lv_event_get_target(event)), LV_EVENT_CLICKED, nullptr); + lv_obj_send_event(close, LV_EVENT_CLICKED, nullptr); } } // namespace Frame create(lv_obj_t* parent, const Config& config) @@ -39,8 +43,17 @@ Frame create(lv_obj_t* parent, const Config& config) lv_obj_set_style_pad_all(frame.panel, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(frame.panel, 0, LV_PART_MAIN); lv_obj_clear_flag(frame.panel, LV_OBJ_FLAG_SCROLLABLE); - ::ui::widgets::top_bar_create_dialog_header(frame.panel, config.title, config.on_close, config.context, &frame.close); - lv_obj_add_event_cb(frame.close, close_key, LV_EVENT_KEY, nullptr); + ::ui::widgets::TopBar header{}; + ::ui::widgets::TopBarConfig header_config{}; + header_config.create_back = false; + header_config.power_indicator = false; + ::ui::widgets::top_bar_init(header, frame.panel, header_config); + lv_obj_del(header.right_label); + ::ui::widgets::top_bar_set_title(header, ::ui::i18n::tr(config.title)); + frame.close = bottom_bar::add_button(header.container, LV_SYMBOL_CLOSE, config.on_close, config.context); + const auto size = std::max(16, lv_obj_get_height(header.container) - 4); + lv_obj_set_size(frame.close, size, size); + lv_obj_set_style_pad_all(frame.close, 0, LV_PART_MAIN); frame.body = lv_obj_create(frame.panel); lv_obj_remove_style_all(frame.body); lv_obj_set_size(frame.body, LV_PCT(100), 0); @@ -51,9 +64,38 @@ Frame create(lv_obj_t* parent, const Config& config) lv_obj_add_flag(frame.body, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scroll_dir(frame.body, LV_DIR_VER); lv_obj_set_scrollbar_mode(frame.body, LV_SCROLLBAR_MODE_AUTO); - if (config.on_key) - for (auto* object : {frame.overlay, frame.panel, frame.body, frame.close}) - lv_obj_add_event_cb(object, config.on_key, LV_EVENT_KEY, config.context); + for (auto* object : {frame.overlay, frame.panel, frame.body, frame.close}) + { + lv_obj_add_event_cb(object, close_key, static_cast(LV_EVENT_KEY | LV_EVENT_PREPROCESS), frame.close); + if (config.on_key) lv_obj_add_event_cb(object, config.on_key, LV_EVENT_KEY, config.context); + } return frame; } + +void fit_content_height(const Frame& frame) +{ + lv_obj_set_flex_grow(frame.body, 0); + lv_obj_set_height(frame.body, LV_SIZE_CONTENT); + lv_obj_update_layout(frame.panel); + const auto wanted = lv_obj_get_height(lv_obj_get_child(frame.panel, 0)) + lv_obj_get_height(frame.body) + + 2 * lv_obj_get_style_border_width(frame.panel, LV_PART_MAIN); + lv_obj_set_height(frame.panel, std::min(wanted, lv_obj_get_height(frame.overlay) - 12)); + lv_obj_set_height(frame.body, 0); + lv_obj_set_flex_grow(frame.body, 1); + lv_obj_center(frame.panel); +} + +void bind_close_shortcuts(const Frame& frame, lv_group_t* group) +{ + if (!group) return; + for (uint32_t i = 0; i < lv_group_get_obj_count(group); ++i) + { + auto* object = lv_group_get_obj_by_index(group, i); + auto* ancestor = object; + while (ancestor && ancestor != frame.overlay) ancestor = lv_obj_get_parent(ancestor); + if (!ancestor) continue; + lv_obj_remove_event_cb(object, close_key); + lv_obj_add_event_cb(object, close_key, static_cast(LV_EVENT_KEY | LV_EVENT_PREPROCESS), frame.close); + } +} } // namespace ui::components::dialog_frame diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index a94d8eda..e3ca69ce 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -3457,7 +3457,7 @@ void open_map_help_modal() add_help_row("I", nullptr, "Hide info, keep route"); add_help_row("Route", nullptr, "Shown when route active"); add_help_row("Members", nullptr, "Shown when team active"); - add_help_row(help_key_label(), "Back", "Close help"); + add_help_row("X/H", "Back", "Close help"); lv_obj_move_foreground(s_map_help_modal); if (app_g) @@ -3465,6 +3465,7 @@ void open_map_help_modal() lv_group_remove_all_objs(app_g); lv_group_add_obj(app_g, body); lv_group_add_obj(app_g, close); + ::ui::components::dialog_frame::bind_close_shortcuts(frame, app_g); lv_group_focus_obj(body); } } @@ -3662,6 +3663,7 @@ void open_tracker_modal(bool navigation) lv_group_add_obj(app_g, obj); } } + ::ui::components::dialog_frame::bind_close_shortcuts(frame, app_g); if (first_focus) { lv_group_focus_obj(first_focus); @@ -3754,6 +3756,7 @@ void open_map_management(MapControlAction action) if (app_g) { lv_group_add_obj(app_g, frame.close); + ::ui::components::dialog_frame::bind_close_shortcuts(frame, app_g); lv_group_focus_obj(first); } lv_obj_move_foreground(s_tracker_modal); diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp index 7d0b9e85..4c16fc87 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_navigation_dialog.cpp @@ -241,6 +241,7 @@ bool open(lv_obj_t* parent, Completed completed, void* context) lv_obj_add_state(state.save, LV_STATE_DISABLED); state.timer = lv_timer_create(poll, 150, nullptr); platform::ui::gps::acquire_power_lease("navigation"); + ::ui::components::dialog_frame::bind_close_shortcuts(frame, state.group); lv_group_focus_obj(state.destination); return true; } diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index 428271a7..312fe010 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -2218,11 +2218,12 @@ void open_route_preview_help_modal() add_help_row("W/S", "Up/Dn", "Select image"); add_help_row("Enter", nullptr, "Open/close image"); add_help_row("Pin", nullptr, "Select image point"); - add_help_row("H", "Back", "Close help"); + add_help_row("X/H", "Back", "Close help"); lv_obj_move_foreground(state.route_preview_help_modal); lv_group_add_obj(state.modal_group, win); lv_group_add_obj(state.modal_group, frame.close); + ::ui::components::dialog_frame::bind_close_shortcuts(frame, state.modal_group); lv_group_focus_obj(win); } @@ -3555,7 +3556,7 @@ lv_obj_t* create_action_menu_button(lv_obj_t* parent, const char* text) lv_obj_t* label = lv_label_create(btn); ::ui::i18n::set_label_text(label, text); lv_obj_center(label); - apply_action_button(btn, label); + ::ui::components::bottom_bar::style_item(btn); return btn; } @@ -3617,38 +3618,9 @@ void open_action_menu_modal() return; } - bool has_item = false; - int action_count = 1; // Cancel - if (state.mode == TrackerPageState::Mode::Record) - { - has_item = state.selected_record_idx >= 0 && - state.selected_record_idx < static_cast(s_record_names.size()); - if (can_delete_selected_item()) - { - ++action_count; - } - } - else - { - has_item = state.selected_route_idx >= 0 && - state.selected_route_idx < static_cast(s_route_names.size()); - if (can_load_selected_route()) - { - ++action_count; - } - if (can_unload_active_route()) - { - ++action_count; - } - if (can_preview_selected_route()) - { - ++action_count; - } - if (can_delete_selected_item()) - { - ++action_count; - } - } + const bool has_item = state.mode == TrackerPageState::Mode::Record + ? state.selected_record_idx >= 0 && state.selected_record_idx < static_cast(s_record_names.size()) + : state.selected_route_idx >= 0 && state.selected_route_idx < static_cast(s_route_names.size()); if (!has_item) { @@ -3656,13 +3628,9 @@ void open_action_menu_modal() } modal_prepare_group(); - const int max_modal_h = page_profile().large_touch_hitbox ? 320 : 220; - const int modal_content_h = 56 + static_cast(action_count) * - static_cast(action_menu_button_height() + action_menu_row_gap()); - const int modal_h = std::min(max_modal_h, modal_content_h); const char* title = (state.mode == TrackerPageState::Mode::Record) ? "Track Actions" : "Route Actions"; const auto frame = ::ui::components::dialog_frame::create(lv_screen_active(), - {title, 190, static_cast(modal_h), [](lv_event_t*) + {title, 200, 180, [](lv_event_t*) { modal_close(g_tracker_state.action_menu_modal); focus_main_panel(); @@ -3671,12 +3639,7 @@ void open_action_menu_modal() state.action_menu_modal = frame.overlay; lv_group_add_obj(state.modal_group, frame.close); lv_obj_t* list = frame.body; - lv_obj_set_flex_align(list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); - lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_PART_MAIN); - lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); lv_obj_set_style_pad_row(list, action_menu_row_gap(), LV_PART_MAIN); - lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_AUTO); lv_obj_t* first_focus = nullptr; auto add_action = [&](ActionMenuCommand cmd, const char* text) @@ -3702,7 +3665,7 @@ void open_action_menu_modal() } if (can_unload_active_route()) { - add_action(ActionMenuCommand::Unload, "Off"); + add_action(ActionMenuCommand::Unload, "Unload"); } if (can_preview_selected_route()) { @@ -3713,7 +3676,8 @@ void open_action_menu_modal() { add_action(ActionMenuCommand::Delete, "Delete"); } - add_action(ActionMenuCommand::Cancel, "Cancel"); + ::ui::components::dialog_frame::fit_content_height(frame); + ::ui::components::dialog_frame::bind_close_shortcuts(frame, state.modal_group); if (first_focus) { @@ -4035,6 +3999,7 @@ void open_delete_confirm_modal() lv_group_add_obj(state.modal_group, confirm_btn); lv_group_add_obj(state.modal_group, cancel_btn); + ::ui::components::dialog_frame::bind_close_shortcuts(frame, state.modal_group); lv_group_focus_obj(cancel_btn); } diff --git a/modules/ui_shared/src/ui/widgets/top_bar.cpp b/modules/ui_shared/src/ui/widgets/top_bar.cpp index 5b0e0925..326f2b74 100644 --- a/modules/ui_shared/src/ui/widgets/top_bar.cpp +++ b/modules/ui_shared/src/ui/widgets/top_bar.cpp @@ -9,8 +9,6 @@ #include #include "ui/assets/fonts/font_utils.h" -#include "ui/components/bottom_bar.h" -#include "ui/localization.h" #include "ui/page/page_profile.h" #include "ui/ui_theme.h" #include "ui/widgets/top_bar_power_presenter.h" @@ -269,24 +267,5 @@ void top_bar_set_back_callback(TopBar& bar, void (*cb)(void*), void* user_data) bar.back_user_data = user_data; } -lv_obj_t* top_bar_create_dialog_header(lv_obj_t* parent, const char* title, - lv_event_cb_t on_close, void* context, lv_obj_t** close_button) -{ - TopBar bar{}; - TopBarConfig config{}; - // Neither a back callback nor the power presenter retains this local view. - config.create_back = false; - config.power_indicator = false; - top_bar_init(bar, parent, config); - lv_obj_del(bar.right_label); - top_bar_set_title(bar, ::ui::i18n::tr(title)); - auto* close = ::ui::components::bottom_bar::add_button(bar.container, LV_SYMBOL_CLOSE, on_close, context); - const auto size = std::max(16, resolve_top_bar_height(config) - 4); - lv_obj_set_size(close, size, size); - lv_obj_set_style_pad_all(close, 0, LV_PART_MAIN); - if (close_button) *close_button = close; - return bar.container; -} - } // namespace widgets } // namespace ui diff --git a/tests/site/tracker-page.test.mjs b/tests/site/tracker-page.test.mjs index 926d0291..8c9710d5 100644 --- a/tests/site/tracker-page.test.mjs +++ b/tests/site/tracker-page.test.mjs @@ -50,7 +50,11 @@ for(const device of ['pager','tdeck','wio-tracker-l2']) { assert.ok(snapshot().some(item=>item.text==='Route Actions'),`${device}: explicit card action opens the existing route menu`); const dialogClose=snapshot().findLast(item=>item.button&&item.text==='\uf00d'); assert.ok(dialogClose&&dialogClose.w===dialogClose.h&&dialogClose.h<=24,`${device}: card actions use a compact title-bar close`); - click('Cancel'); + assert.ok(!snapshot().some(item=>item.button&&item.text==='Cancel'),`${device}: title-bar close avoids a duplicate cancel row`); + const lastAction=snapshot().find(item=>item.button&&item.text==='Delete'); + assert.ok(lastAction&&lastAction.y+lastAction.h-dialogClose.y<140,`${device}: action list fits its contents`); + runtime._native_key('X'.charCodeAt(0));tick(); + assert.ok(!snapshot().some(item=>item.text==='Route Actions'),`${device}: uppercase X closes from the focused action`); const routeCounter=snapshot().find(item=>item.text==='1 / 2'); assert.ok(routeCounter,`${device}: card shows the selected position`); click('\uf054'); @@ -70,12 +74,15 @@ for(const device of ['pager','tdeck','wio-tracker-l2']) { click('\uf053'); assert.equal(runtime._native_page_value(),10,'Top-bar back remains available without a list Back row'); runtime._native_open_page(15);tick(); - const closeDialog=()=>{ + const closeDialog=(keyboard=false)=>{ const close=snapshot().findLast(item=>item.button&&item.text==='\uf00d'); assert.ok(close&&close.w===close.h&&close.h<=24,`${device}: modal has a compact square close`); const x=close.x+Math.floor(close.w/2),y=close.y+Math.floor(close.h/2); - runtime._native_pointer(x,y,1);runtime._native_tick(50); - runtime._native_pointer(x,y,0);tick(); + if(keyboard) {runtime._native_key('x'.charCodeAt(0));tick();} + else { + runtime._native_pointer(x,y,1);runtime._native_tick(50); + runtime._native_pointer(x,y,0);tick(); + } assert.ok(!snapshot().some(item=>item.button&&item.text==='\uf00d'),`${device}: touch closes the topmost modal`); }; click('Help'); @@ -91,7 +98,7 @@ for(const device of ['pager','tdeck','wio-tracker-l2']) { } // Checkbox text is drawn by the checkbox widget itself and is not a label // in native_snapshot. The shared title and real pointer-close remain visible. - closeDialog(); + closeDialog(section!=='Contours'); } console.log(`${device}: empty state, recording, reused file cards, selection across categories, navigation and top-bar back passed.`); } diff --git a/tests/tracker_card_view/test_dialog_frame.cpp b/tests/tracker_card_view/test_dialog_frame.cpp index dddadd7c..1e824153 100644 --- a/tests/tracker_card_view/test_dialog_frame.cpp +++ b/tests/tracker_card_view/test_dialog_frame.cpp @@ -1,4 +1,5 @@ #include "ui/assets/fonts/font_utils.h" +#include "ui/components/bottom_bar.h" #include "ui/components/dialog_frame.h" #include "ui/widgets/top_bar.h" #include @@ -54,7 +55,7 @@ int main() bar_config.power_indicator = false; ui::widgets::top_bar_init(page_bar, screen, bar_config); const auto retained = lv_obj_get_child_count(screen); - for (const bool keyboard : {false, true}) + for (const uint32_t key : {0U, uint32_t(LV_KEY_ENTER), uint32_t('x'), uint32_t('X')}) { struct Context { @@ -73,6 +74,17 @@ int main() context.overlay = frame.overlay; for (unsigned line = 0; line < 20; ++line) lv_label_set_text(lv_label_create(frame.body), "Scrollable route details"); + auto* nested = lv_btn_create(frame.body); + auto* group = lv_group_create(); + lv_group_add_obj(group, nested); + bool normal_handler_called = false; + lv_obj_add_event_cb( + nested, [](lv_event_t* event) + { + *static_cast(lv_event_get_user_data(event)) = true; + lv_event_stop_processing(event); }, + LV_EVENT_KEY, &normal_handler_called); + ui::components::dialog_frame::bind_close_shortcuts(frame, group); lv_obj_update_layout(screen); auto* header = lv_obj_get_child(frame.panel, 0); assert(lv_obj_get_height(header) == lv_obj_get_height(page_bar.container)); @@ -89,16 +101,35 @@ int main() lv_obj_scroll_to_y(frame.body, 120, LV_ANIM_OFF); lv_obj_update_layout(screen); assert(lv_obj_get_y(header) == 0); - if (keyboard) + if (key) { - uint32_t enter = LV_KEY_ENTER; - lv_obj_send_event(frame.close, LV_EVENT_KEY, &enter); + auto value = key; + lv_obj_send_event(key == LV_KEY_ENTER ? frame.close : nested, LV_EVENT_KEY, &value); } else lv_obj_send_event(frame.close, LV_EVENT_CLICKED, nullptr); assert(context.closed == 1 && !context.overlay); + assert(!normal_handler_called); assert(lv_obj_get_child_count(screen) == retained); + lv_group_del(group); } + const auto compact = ui::components::dialog_frame::create(screen, {"Route Actions", 200, 180}); + for (const auto text : {"Load", "Preview", "Delete"}) + { + auto* row = ui::components::bottom_bar::add_button(compact.body, text); + ui::components::bottom_bar::style_item(row); + lv_obj_set_size(row, LV_PCT(100), 28); + } + ui::components::dialog_frame::fit_content_height(compact); + lv_obj_update_layout(screen); + assert(lv_obj_get_height(compact.panel) < 140); + assert(lv_obj_get_style_pad_top(compact.body, LV_PART_MAIN) == 6); + auto* last = lv_obj_get_child(compact.body, -1); + lv_area_t body_area{}, last_area{}; + lv_obj_get_coords(compact.body, &body_area); + lv_obj_get_coords(last, &last_area); + assert(body_area.y2 - last_area.y2 == 6); + lv_obj_del(compact.overlay); lv_display_delete(display); - std::printf("%dpx: shared title-bar metrics, bounded body, fixed header and touch/key close passed.\n", width); + std::printf("%dpx: shared header, bounded body, compact action list and touch/Enter/X/x close passed.\n", width); } }