diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h index db0c03e0..6666f799 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h @@ -82,8 +82,10 @@ public: } const ExternalVector& bytes() const { return _bytes; } std::size_t size() const { return _bytes.size(); } + std::size_t capacity() const { return _bytes.capacity(); } bool truncated() const { return false; } void clear() { _bytes.clear(); } + void release() { ExternalVector().swap(_bytes); } private: ExternalVector _bytes; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp index a6e7947f..3c8d5260 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #if defined(PYXIS_NOMAD_MEMORY_DIAGNOSTIC) #include #endif @@ -133,7 +134,15 @@ void NomadNetScreen::clear_document(){ } _page.clear(); NomadNet::ExternalVector().swap(_page_layout); + NomadNet::ExternalVector().swap(_line_layout); + NomadNet::ExternalVector().swap(_layout_checkpoints); + NomadNet::ExternalVector().swap(_link_y); + NomadNet::ExternalVector().swap(_link_bottom); _page_height=0; + _physical_extent=0; + _logical_scroll=0; + _layout_window_top=0; + _layout_window_bottom=0; _selected_link=-1; lv_obj_refresh_self_size(_content); lv_obj_invalidate(_content); @@ -335,48 +344,119 @@ bool NomadNetScreen::set_page(const NomadNet::Document& document) { set_status("Page is too large for available memory"); return false; } - if(_page.truncated()){ - const std::string notice=NomadNet::truncation_notice(document); - if(!_page.append_notice(notice)){ + try { + if(_page.truncated()){ + const std::string notice=NomadNet::truncation_notice(document); + if(!_page.append_notice(notice)){ + clear_document(); + set_status("Page truncation notice could not be retained"); + return false; + } + } + else if(_page.unsupported()&&!_page.append_notice("[Unsupported Micron content]")){ clear_document(); - set_status("Page truncation notice could not be retained"); + set_status("Unsupported-content notice could not be retained"); return false; } - } - else if(_page.unsupported())_page.append_notice("[Unsupported Micron content]"); - if(!layout_page()){ + show_browser(false); + if(!layout_page()){ + clear_document(); + set_status("Page viewport could not be retained"); + return false; + } + lv_obj_set_style_bg_color(_content,_page.has_background() + ?lv_color_hex(_page.background()):Theme::surface(),0); + _page_loaded=true; + lv_obj_scroll_to_y(_content,0,LV_ANIM_OFF); + lv_obj_refresh_self_size(_content); + lv_obj_invalidate(_content); + return true; + }catch(const std::bad_alloc&){ clear_document(); - set_status("Page layout notice could not be retained"); + set_status("Page is too large for available memory"); return false; } - lv_obj_set_style_bg_color(_content,_page.has_background() - ?lv_color_hex(_page.background()):Theme::surface(),0); - _page_loaded=true; - show_browser(false); - lv_obj_scroll_to_y(_content,0,LV_ANIM_OFF); - lv_obj_refresh_self_size(_content); - lv_obj_invalidate(_content); - return true; } bool NomadNetScreen::layout_page(){ _page_layout.clear(); - _page_layout.reserve(std::min(_page.runs().size()*4,MAX_LAYOUT_FRAGMENTS)); - constexpr std::size_t content_fragment_limit=MAX_LAYOUT_FRAGMENTS-1; + _line_layout.clear(); + _layout_checkpoints.clear(); + _link_y.assign(_page.links().size(),-1); + _link_bottom.assign(_page.links().size(),-1); + _page_layout.reserve(MAX_WINDOW_FRAGMENTS); + _line_layout.reserve(NomadNet::DocumentParser::MAX_RUNS_PER_LINE); + _layout_checkpoints.reserve(_page.blocks().size()); + const int32_t viewport=std::max(1,lv_obj_get_content_height(_content)); + if(!layout_from(0,0,0,viewport*3,true))return false; + _physical_extent=std::min(_page_height,MAX_PHYSICAL_SCROLL_EXTENT); + _logical_scroll=0; + _layout_window_top=0; + _layout_window_bottom=std::min(_page_height,viewport*3); + return true; +} + +bool NomadNetScreen::append_line_fragment(const LayoutFragment& fragment){ + if(!_line_layout.empty()){ + auto& previous=_line_layout.back(); + const uint32_t combined=static_cast(previous.byte_length)+fragment.byte_length; + if(previous.link_index==fragment.link_index&&previous.large_font==fragment.large_font&& + combined<256&&NomadNet::VirtualViewport::can_coalesce(previous.run_index, + previous.byte_offset,previous.byte_length,fragment.run_index,fragment.byte_offset)){ + previous.byte_length=static_cast(combined); + previous.width=static_cast(previous.width+fragment.width); + return true; + } + } + if(_line_layout.size()>=NomadNet::DocumentParser::MAX_RUNS_PER_LINE)return false; + _line_layout.push_back(fragment); + return true; +} + +bool NomadNetScreen::commit_line(int32_t line_y,int16_t line_height, + NomadNet::Alignment alignment,int16_t indent, + int16_t available,int32_t window_top, + int32_t window_bottom){ + if(_line_layout.empty())return true; + int16_t line_width=0; + for(const auto& fragment:_line_layout) + line_width=std::max(line_width,fragment.x+fragment.width-indent); + const int16_t shift=alignment==NomadNet::Alignment::CENTER?(available-line_width)/2: + alignment==NomadNet::Alignment::RIGHT?available-line_width:0; + if(line_y+line_height>=window_top&&line_y=MAX_WINDOW_FRAGMENTS)return false; + if(shift>0)fragment.x=static_cast(fragment.x+shift); + const int32_t relative_y=line_y-window_top; + if(relative_yINT16_MAX)return false; + fragment.y=static_cast(relative_y); + _page_layout.push_back(fragment); + } + } + _line_layout.clear(); + return true; +} + +bool NomadNetScreen::layout_from(std::size_t start_block,int32_t start_y, + int32_t window_top,int32_t window_bottom, + bool build_index){ constexpr int16_t width=304; - int16_t y=0; - bool content_remaining=false; - for(std::size_t block_index=0;block_index<_page.blocks().size();++block_index){ - if(_page_layout.size()>=content_fragment_limit){ - content_remaining=std::any_of(_page.blocks().begin()+block_index,_page.blocks().end(), - [](const NomadNet::CompactPage::BlockRecord& candidate){ - return NomadNet::block_has_layout_content(candidate.type,candidate.run_count); - }); - break; + int32_t y=start_y; + for(std::size_t block_index=start_block;block_index<_page.blocks().size();++block_index){ + if(!build_index&&y>=window_bottom)break; + if(build_index){ + if(_layout_checkpoints.size()>=NomadNet::CompactPage::MAX_BLOCKS)return false; + _layout_checkpoints.push_back(LayoutCheckpoint{ + static_cast(block_index),y}); } const auto& block=_page.blocks()[block_index]; if(block.type==NomadNet::BlockType::DIVIDER){ - _page_layout.push_back(LayoutFragment(0,0,0,-1,0,y,width,1,true));y+=9;continue; + if(y+1>=window_top&&y=MAX_WINDOW_FRAGMENTS)return false; + _page_layout.push_back(LayoutFragment(0,0,0,-1,0, + static_cast(y-window_top),width,1,true)); + } + y+=9;continue; } if(block.run_count==0)continue; const int16_t indent=block.type==NomadNet::BlockType::HEADING?0: @@ -384,20 +464,9 @@ bool NomadNetScreen::layout_page(){ const int16_t available=width-indent; int16_t x=indent; int16_t line_h=16; - std::size_t line_first=_page_layout.size(); - int16_t line_y=y; - auto align_line=[&](std::size_t begin,std::size_t end,int16_t current_y){ - if(begin>=end||block.alignment==NomadNet::Alignment::LEFT)return; - int16_t line_width=0; - for(std::size_t i=begin;i(line_width,_page_layout[i].x+_page_layout[i].width-indent); - const int16_t shift=block.alignment==NomadNet::Alignment::CENTER - ?(available-line_width)/2:available-line_width; - if(shift>0)for(std::size_t i=begin;i=content_fragment_limit){content_remaining=true;break;} const uint16_t run_index=static_cast(block.first_run+r); const auto& run=_page.runs()[run_index]; const auto text=_page.text(run); @@ -407,7 +476,6 @@ bool NomadNetScreen::layout_page(){ line_h=std::max(line_h,height); std::size_t offset=0; while(offset=content_fragment_limit){content_remaining=true;break;} std::size_t end=offset; const bool whitespace=text[offset]==' '||text[offset]=='\t'; while(end(end-offset),font,0,LV_TEXT_FLAG_NONE)); if(whitespace&&x+fragment_w>indent+available){offset=end;continue;} if(!whitespace&&x>indent&&x+fragment_w>indent+available){ - align_line(line_first,_page_layout.size(),line_y); - y+=line_h;x=indent;line_h=height;line_first=_page_layout.size();line_y=y; + if(!commit_line(line_y,line_h,block.alignment,indent,available, + window_top,window_bottom))return false; + y+=line_h;x=indent;line_h=height;line_y=y; } if(fragment_w>available){ end=offset; @@ -434,51 +503,82 @@ bool NomadNetScreen::layout_page(){ } } if(!(whitespace&&x==indent)){ - _page_layout.push_back(LayoutFragment(run_index,static_cast(offset), - static_cast(end-offset),run.link_index,x,y,fragment_w,height,false, - large)); + if(run.link_index>=0&&static_cast(run.link_index)<_link_y.size()){ + if(_link_y[run.link_index]<0)_link_y[run.link_index]=line_y; + _link_bottom[run.link_index]=std::max(_link_bottom[run.link_index],line_y+height); + } + if(!append_line_fragment(LayoutFragment(run_index,static_cast(offset), + static_cast(end-offset),run.link_index,x,0,fragment_w,height,false, + large)))return false; x=static_cast(x+fragment_w); } offset=end; } - if(content_remaining)break; } - align_line(line_first,_page_layout.size(),line_y); + if(!commit_line(line_y,line_h,block.alignment,indent,available, + window_top,window_bottom))return false; y+=line_h+3; - if(content_remaining)break; } - if(NomadNet::layout_content_truncated(_page_layout.size(),content_fragment_limit, - content_remaining)){ - const std::string layout_notice="[Page layout truncated: "+ - std::to_string(content_fragment_limit)+" fragments]"; - if(!_page.append_notice(layout_notice))return false; - const uint16_t notice_run_index=static_cast(_page.runs().size()-1); - _page_layout.erase(std::remove_if(_page_layout.begin(),_page_layout.end(), - [notice_run_index](const LayoutFragment& fragment){ - return !fragment.divider&&fragment.run_index>=notice_run_index; - }),_page_layout.end()); - const auto notice=_page.text(_page.runs()[notice_run_index]); - const int16_t notice_width=static_cast(std::min(304, - lv_txt_get_width(notice.data(),static_cast(notice.size()), - &nomadnet_font_12,0,LV_TEXT_FLAG_NONE))); - _page_layout.push_back(LayoutFragment(notice_run_index,0, - static_cast(notice.size()),-1,0,y,notice_width,19,false)); - y+=22; - } - _page_height=std::max(y,lv_obj_get_content_height(_content)); + if(build_index)_page_height=std::max(y,lv_obj_get_content_height(_content)); return true; } +bool NomadNetScreen::layout_window(int32_t logical_scroll){ + const int32_t viewport=std::max(1,lv_obj_get_content_height(_content)); + const int32_t window_top=NomadNet::VirtualViewport::window_top(logical_scroll,viewport); + const int32_t window_bottom=NomadNet::VirtualViewport::window_bottom( + logical_scroll,viewport,_page_height); + if(window_top==_layout_window_top&&window_bottom==_layout_window_bottom)return true; + std::size_t start_block=0; + int32_t start_y=0; + for(const auto& checkpoint:_layout_checkpoints){ + if(checkpoint.y>window_top)break; + start_block=checkpoint.block_index; + start_y=checkpoint.y; + } + _page_layout.clear(); + if(!layout_from(start_block,start_y,window_top,window_bottom,false))return false; + _layout_window_top=window_top; + _layout_window_bottom=window_bottom; + return true; +} + +int32_t NomadNetScreen::logical_scroll_from_widget()const{ + return NomadNet::VirtualViewport::logical_from_physical( + lv_obj_get_scroll_y(_content),_page_height, + lv_obj_get_content_height(_content),_physical_extent); +} + +void NomadNetScreen::scroll_to_logical(int32_t logical,lv_anim_enable_t animation){ + const int32_t viewport=std::max(1,lv_obj_get_content_height(_content)); + const int32_t logical_max=std::max(0,_page_height-viewport); + const int32_t target=std::max(0,std::min(logical,logical_max)); + const int32_t physical=NomadNet::VirtualViewport::physical_from_logical( + target,_page_height,viewport,_physical_extent); + if(animation==LV_ANIM_OFF){ + _logical_scroll=target; + layout_window(_logical_scroll); + } + lv_obj_scroll_to_y(_content,physical,animation); + lv_obj_invalidate(_content); +} + void NomadNetScreen::draw_page(lv_event_t* event){ auto* draw_ctx=lv_event_get_draw_ctx(event); - const int16_t scroll=lv_obj_get_scroll_y(_content); - const int16_t top=_content->coords.y1+lv_obj_get_style_pad_top(_content,0); - const int16_t left=_content->coords.x1+lv_obj_get_style_pad_left(_content,0); + lv_area_t content_area; + lv_obj_get_content_coords(_content,&content_area); + lv_area_t content_clip; + if(!_lv_area_intersect(&content_clip,draw_ctx->clip_area,&content_area))return; + const lv_area_t* original_clip=draw_ctx->clip_area; + draw_ctx->clip_area=&content_clip; + const int16_t top=content_area.y1; + const int16_t left=content_area.x1; char scratch[256]; for(const auto& fragment:_page_layout){ - const int16_t draw_y=static_cast(top+fragment.y-scroll); + const int32_t logical_y=_layout_window_top+fragment.y; + const int32_t draw_y=top+logical_y-_logical_scroll; if(draw_y+fragment.height<_content->coords.y1||draw_y>_content->coords.y2)continue; - lv_area_t area{static_cast(left+fragment.x),draw_y, + lv_area_t area{static_cast(left+fragment.x),static_cast(draw_y), static_cast(left+fragment.x+std::max(fragment.width,1)-1), static_cast(draw_y+std::max(fragment.height,1)-1)}; if(fragment.divider){lv_draw_rect_dsc_t dsc;lv_draw_rect_dsc_init(&dsc);dsc.bg_color=Theme::border();lv_draw_rect(draw_ctx,&dsc,&area);continue;} @@ -500,9 +600,9 @@ void NomadNetScreen::draw_page(lv_event_t* event){ if(_selected_link>=0){ NomadNet::for_each_focus_span(_page_layout,_selected_link,[&](const NomadNet::FocusSpan& span){ if(span.run_index>=_page.runs().size())return; - const int16_t draw_y=static_cast(top+span.y-scroll); + const int32_t draw_y=top+_layout_window_top+span.y-_logical_scroll; if(draw_y+span.height<_content->coords.y1||draw_y>_content->coords.y2)return; - lv_area_t area{static_cast(left+span.x),draw_y, + lv_area_t area{static_cast(left+span.x),static_cast(draw_y), static_cast(left+span.x+std::max(span.width,1)-1), static_cast(draw_y+std::max(span.height,1)-1)}; lv_draw_rect_dsc_t focus;lv_draw_rect_dsc_init(&focus);focus.bg_opa=LV_OPA_TRANSP; @@ -511,30 +611,28 @@ void NomadNetScreen::draw_page(lv_event_t* event){ focus.border_width=1;lv_draw_rect(draw_ctx,&focus,&area); }); } + draw_ctx->clip_area=original_clip; } void NomadNetScreen::select_link(int direction){ if(_page.links().empty()){ - const int next=std::max(0,lv_obj_get_scroll_y(_content)+direction*40); - lv_obj_scroll_to_y(_content,next,LV_ANIM_ON); + scroll_to_logical(_logical_scroll+direction*40,LV_ANIM_ON); return; } const int count=static_cast(_page.links().size()); int candidate=_selected_link; for(int attempt=0;attempt=0?0:count-1):(candidate+direction+count)%count; - bool rendered=false; - for(const auto& fragment:_page_layout)if(fragment.link_index==candidate){rendered=true;break;} - if(rendered){_selected_link=candidate;break;} + const bool laid_out=static_cast(candidate)<_link_y.size()&&_link_y[candidate]>=0; + if(laid_out){_selected_link=candidate;break;} } if(_selected_link<0)return; - for(const auto& fragment:_page_layout)if(fragment.link_index==_selected_link){ - const int top=fragment.y;const int bottom=fragment.y+fragment.height; - const int scroll=lv_obj_get_scroll_y(_content);const int visible=lv_obj_get_content_height(_content); - if(topscroll+visible)lv_obj_scroll_to_y(_content,bottom-visible,LV_ANIM_ON); - break; - } + const int32_t link_top=_link_y[_selected_link]; + const int32_t link_bottom=_link_bottom[_selected_link]; + const int32_t visible=lv_obj_get_content_height(_content); + if(link_top<_logical_scroll)scroll_to_logical(link_top,LV_ANIM_ON); + else if(link_bottom>_logical_scroll+visible) + scroll_to_logical(link_bottom-visible,LV_ANIM_ON); lv_obj_invalidate(_content); } @@ -549,7 +647,12 @@ void NomadNetScreen::page_event(lv_event_t* event){ auto* self=static_cast(lv_event_get_user_data(event)); const auto code=lv_event_get_code(event); if(code==LV_EVENT_DRAW_MAIN)self->draw_page(event); - else if(code==LV_EVENT_GET_SELF_SIZE){auto* size=static_cast(lv_event_get_param(event));size->y=std::max(size->y,self->_page_height);} + else if(code==LV_EVENT_GET_SELF_SIZE){auto* size=static_cast(lv_event_get_param(event));size->y=std::max(size->y,static_cast(self->_physical_extent));} + else if(code==LV_EVENT_SCROLL){ + self->_logical_scroll=self->logical_scroll_from_widget(); + if(!self->layout_window(self->_logical_scroll))self->set_status("Page viewport could not be retained"); + lv_obj_invalidate(self->_content); + } else if(code==LV_EVENT_FOCUSED){ auto* group=static_cast(lv_obj_get_group(self->_content)); if(group)lv_group_set_editing(group,true); @@ -571,10 +674,19 @@ void NomadNetScreen::page_event(lv_event_t* event){ else if(code==LV_EVENT_CLICKED){ auto* indev=lv_indev_get_act(); if(!indev||lv_indev_get_type(indev)!=LV_INDEV_TYPE_POINTER)return; - lv_point_t point;lv_indev_get_point(indev,&point);const int scroll=lv_obj_get_scroll_y(self->_content); - const int x=point.x-self->_content->coords.x1-lv_obj_get_style_pad_left(self->_content,0); - const int y=point.y-self->_content->coords.y1-lv_obj_get_style_pad_top(self->_content,0)+scroll; - for(const auto& fragment:self->_page_layout)if(fragment.link_index>=0&&x>=fragment.x&&x=fragment.y&&y_selected_link=fragment.link_index;self->activate_selected_link();break;} + lv_point_t point;lv_indev_get_point(indev,&point); + lv_area_t content_area; + lv_obj_get_content_coords(self->_content,&content_area); + if(!_lv_area_is_point_on(&content_area,&point,0))return; + const int x=point.x-content_area.x1; + const int32_t y=point.y-content_area.y1+self->_logical_scroll; + for(const auto& fragment:self->_page_layout){ + const int32_t fragment_y=self->_layout_window_top+fragment.y; + if(fragment.link_index>=0&&x>=fragment.x&&x=fragment_y&&y_selected_link=fragment.link_index;self->activate_selected_link();break; + } + } } } void NomadNetScreen::show(){ diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h index 47f96393..76f8f8f5 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h @@ -8,6 +8,7 @@ #include "NomadNetCompactPage.h" #include "NomadNetDocument.h" #include "NomadNetLibrary.h" +#include "NomadNetVirtualViewport.h" namespace UI::LXMF { class NomadNetScreen { @@ -35,9 +36,11 @@ public: bool directory_visible() const { return _directory_visible.load(std::memory_order_acquire); } void show(); void hide(); private: - // 1024 wrapped fragments remain below LVGL 8.x's signed 16-bit vertical - // coordinate range even at the 16 px heading font's 19 px line box. - static constexpr std::size_t MAX_LAYOUT_FRAGMENTS = 1024; + // Only the visible region plus bounded overscan is retained. The parser + // admits at most 1024 runs, so this also covers a pathological viewport + // containing every styled run plus bounded dividers. + static constexpr std::size_t MAX_WINDOW_FRAGMENTS = 1056; + static constexpr int32_t MAX_PHYSICAL_SCROLL_EXTENT = 30000; struct LayoutFragment { uint16_t run_index = 0; uint16_t byte_offset = 0; @@ -56,6 +59,12 @@ private: : run_index(run), byte_offset(offset), byte_length(length), link_index(link), x(left), y(top), width(w), height(h), divider(is_divider), large_font(is_large) {} }; + struct LayoutCheckpoint { + uint16_t block_index = 0; + int32_t y = 0; + LayoutCheckpoint() = default; + LayoutCheckpoint(uint16_t block, int32_t top) : block_index(block), y(top) {} + }; lv_obj_t* _screen=nullptr; lv_obj_t* _back_button=nullptr; lv_obj_t* _home_button=nullptr; lv_obj_t* _reload_button=nullptr; lv_obj_t* _save_button=nullptr; lv_obj_t* _address_row=nullptr; lv_obj_t* _address=nullptr; lv_obj_t* _go_button=nullptr; lv_obj_t* _address_summary=nullptr; lv_obj_t* _edit_button=nullptr; @@ -63,7 +72,15 @@ private: lv_obj_t* _directory=nullptr; NomadNet::CompactPage _page; NomadNet::ExternalVector _page_layout; + NomadNet::ExternalVector _line_layout; + NomadNet::ExternalVector _layout_checkpoints; + NomadNet::ExternalVector _link_y; + NomadNet::ExternalVector _link_bottom; int32_t _page_height = 0; + int32_t _physical_extent = 0; + int32_t _logical_scroll = 0; + int32_t _layout_window_top = 0; + int32_t _layout_window_bottom = 0; int16_t _selected_link = -1; std::vector _directory_focusables; std::vector _directory_targets; @@ -82,6 +99,17 @@ private: void clear_document(); void clear_directory(); bool layout_page(); + bool layout_window(int32_t logical_scroll); + bool layout_from(std::size_t start_block, int32_t start_y, + int32_t window_top, int32_t window_bottom, + bool build_index); + bool append_line_fragment(const LayoutFragment& fragment); + bool commit_line(int32_t line_y, int16_t line_height, + NomadNet::Alignment alignment, int16_t indent, + int16_t available, int32_t window_top, + int32_t window_bottom); + int32_t logical_scroll_from_widget() const; + void scroll_to_logical(int32_t logical, lv_anim_enable_t animation); void draw_page(lv_event_t* event); void select_link(int direction); void activate_selected_link(); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetVirtualViewport.h b/lib/tdeck_ui/UI/LXMF/NomadNetVirtualViewport.h new file mode 100644 index 00000000..f362199f --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetVirtualViewport.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +namespace UI::LXMF::NomadNet { + +class VirtualViewport { +public: + static int32_t logical_from_physical(int32_t physical_scroll, + int32_t logical_height, + int32_t viewport_height, + int32_t physical_extent) { + const int32_t logical_max = std::max(0, logical_height - viewport_height); + const int32_t physical_max = std::max(0, physical_extent - viewport_height); + if (logical_max == 0 || physical_max == 0) return 0; + const int32_t clamped = std::max(0, std::min(physical_scroll, physical_max)); + if (logical_max == physical_max) return clamped; + return static_cast((static_cast(clamped) * logical_max) / physical_max); + } + + static int32_t physical_from_logical(int32_t logical_scroll, + int32_t logical_height, + int32_t viewport_height, + int32_t physical_extent) { + const int32_t logical_max = std::max(0, logical_height - viewport_height); + const int32_t physical_max = std::max(0, physical_extent - viewport_height); + if (logical_max == 0 || physical_max == 0) return 0; + const int32_t clamped = std::max(0, std::min(logical_scroll, logical_max)); + if (logical_max == physical_max) return clamped; + return static_cast((static_cast(clamped) * physical_max) / logical_max); + } + + static int32_t window_top(int32_t logical_scroll, int32_t viewport_height) { + return std::max(0, logical_scroll - viewport_height * 2); + } + + static int32_t window_bottom(int32_t logical_scroll, + int32_t viewport_height, + int32_t logical_height) { + return std::min(logical_height, logical_scroll + viewport_height * 3); + } + + static bool can_coalesce(uint16_t existing_run, + uint16_t existing_offset, + uint16_t existing_length, + uint16_t next_run, + uint16_t next_offset) { + return existing_run == next_run && + static_cast(existing_offset) + existing_length == next_offset; + } +}; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 45da2e5f..68e06fa0 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -2293,6 +2293,7 @@ void UIManager::nomad_update() { _nomadnet_screen->set_library(_nomad_library); page_applied = _nomadnet_screen->set_page(document); } + if (page_applied) _nomad_response.release(); nomad_heap_checkpoint("response-page-applied"); if (!page_applied) { nomad_stop_transport(); diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp index bda78ac9..27e2e33b 100644 --- a/tests/native/test_app_launcher_nomadnet.cpp +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -20,6 +20,7 @@ #include "NomadNetProtocol.h" #include "NomadNetRequestPolicy.h" #include "NomadNetUrl.h" +#include "NomadNetVirtualViewport.h" using UI::LXMF::NavigationStack; using UI::LXMF::Route; @@ -42,6 +43,7 @@ using UI::LXMF::NomadNet::for_each_focus_span; using UI::LXMF::NomadNet::ResponseBuffer; using UI::LXMF::NomadNet::RequestPolicy; using UI::LXMF::NomadNet::Url; +using UI::LXMF::NomadNet::VirtualViewport; int main(int argc, char** argv) { int passed = 0; @@ -585,6 +587,35 @@ int main(int argc, char** argv) { check("divider has layout content without a text run", UI::LXMF::NomadNet::block_has_layout_content(BlockType::DIVIDER, 0)); + check("virtual viewport maps short pages without scaling", + VirtualViewport::logical_from_physical(640, 1200, 150, 1200) == 640 && + VirtualViewport::physical_from_logical(640, 1200, 150, 1200) == 640); + check("virtual viewport maps tall pages through bounded LVGL coordinates", + VirtualViewport::logical_from_physical(14925, 60000, 150, 30000) == 29925 && + VirtualViewport::physical_from_logical(29925, 60000, 150, 30000) == 14925); + check("virtual viewport clamps logical and physical scroll positions", + VirtualViewport::logical_from_physical(40000, 60000, 150, 30000) == 59850 && + VirtualViewport::physical_from_logical(70000, 60000, 150, 30000) == 29850); + check("virtual viewport keeps bounded overscan around the visible region", + VirtualViewport::window_top(30000, 150) == 29700 && + VirtualViewport::window_bottom(30000, 150, 60000) == 30450); + check("overscan window clamps at the document boundaries", + VirtualViewport::window_top(5,75)==0 && + VirtualViewport::window_bottom(5,75,60000)==230); + + std::vector response_bytes(ResponseBuffer::MAX_BYTES,0x5a); + check("response buffer accepts the exact body limit", + response.assign(response_bytes.data(),response_bytes.size()) && + response.capacity()>=response_bytes.size()); + response.release(); + check("response release returns the normalized PSRAM allocation", + response.size()==0 && response.capacity()==0); + check("coalescing accepts adjacent bytes from the same styled run", + VirtualViewport::can_coalesce(7, 12, 5, 7, 17)); + check("coalescing preserves style and link run boundaries", + !VirtualViewport::can_coalesce(7, 12, 5, 8, 17) && + !VirtualViewport::can_coalesce(7, 12, 5, 7, 18)); + check("compact address preserves short values", compact_address("node:/page/a.mu", 32) == "node:/page/a.mu"); check("compact address abbreviates destination but preserves path", compact_address("a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu", 30) == diff --git a/tests/native/test_app_launcher_nomadnet.py b/tests/native/test_app_launcher_nomadnet.py index e17c1424..9bb65150 100644 --- a/tests/native/test_app_launcher_nomadnet.py +++ b/tests/native/test_app_launcher_nomadnet.py @@ -133,10 +133,12 @@ def test_nomadnet_page_body_uses_one_compact_custom_viewport(): assert "lv_label_create(_content)" not in set_page assert "LV_EVENT_DRAW_MAIN" in screen assert "lv_draw_label(" in screen - assert "align_line" in screen + assert "commit_line" in screen assert "NomadNet::truncation_notice(document)" in set_page assert "if(!_page.append_notice(notice))" in set_page assert "if(!layout_page())" in set_page + assert "catch(const std::bad_alloc&)" in set_page + assert "Page is too large for available memory" in set_page assert "[Page truncated to device safety limits]" not in set_page assert "[Unsupported Micron content]" in screen assert "lv_obj_set_scroll_dir(_content,LV_DIR_VER)" in screen @@ -150,17 +152,36 @@ def test_nomadnet_page_body_uses_one_compact_custom_viewport(): assert "const std::string rendered_text" not in compact_cpp assert "visit_display_text" in compact_cpp assert "lv_indev_get_type(indev)!=LV_INDEV_TYPE_POINTER" in screen - assert "const std::string layout_notice=\"[Page layout truncated: \"+" in screen - assert "std::to_string(content_fragment_limit)+\" fragments]\"" in screen - assert "fragment.run_index>=notice_run_index" in screen - assert "notice_run_index" in screen - assert "_page_layout.push_back(LayoutFragment(notice_run_index" in screen + assert "MAX_WINDOW_FRAGMENTS" in screen_h + assert "MAX_LAYOUT_FRAGMENTS" not in screen_h + assert "ExternalVector _layout_checkpoints" in screen_h + assert "_layout_window_top" in screen_h + assert "_logical_scroll" in screen_h + assert "ExternalVector _link_bottom" in screen_h + assert "_link_bottom[run.link_index]=std::max" in screen + assert "link_top+19" not in screen + assert "layout_window(" in screen + assert "VirtualViewport::logical_from_physical" in screen + assert "VirtualViewport::physical_from_logical" in screen + assert "lv_obj_get_content_coords(_content,&content_area)" in screen + assert "draw_ctx->clip_area=&content_clip" in screen + assert "_lv_area_is_point_on(&content_area,&point,0)" in screen + assert "[Page layout truncated:" not in screen + assert "content_fragment_limit" not in screen assert "whitespace&&x+fragment_w>indent+available" in screen assert "for(int attempt=0;attempt=0" in screen assert "lv_group_set_editing(group,false)" in screen +def test_successful_page_application_releases_normalized_response(): + manager = (INCLUDE / "UIManager.cpp").read_text() + apply_start = manager.index("bool page_applied = false;") + apply_end = manager.index("nomad_heap_checkpoint(\"response-page-applied\")", apply_start) + applied = manager[apply_start:apply_end] + assert "_nomad_response.release();" in applied + + def test_directory_rebuild_has_one_final_focus_owner(): """Returning from Heard Nodes must not focus every surviving/start row.""" screen = (INCLUDE / "NomadNetScreen.cpp").read_text()