Merge pull request #77 from torlando-tech/fix/nomadnet-virtual-layout
Build and Deploy Firmware / build-and-deploy (push) Canceled after 0s
Test / Pyxis pytest suite (build_scripts + native) (push) Canceled after 0s
Test / microReticulum native unit tests (PlatformIO native17) (push) Canceled after 0s

Virtualize NomadNet page layout
This commit is contained in:
Torlando authored and GitHub committed 2026-08-15 00:27:33 -04:00
commit 0e67153a4e
7 files changed
+352 -103

No files matched your search

+2
View File
@@ -82,8 +82,10 @@ public:
}
const ExternalVector<uint8_t>& 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<uint8_t>().swap(_bytes); }
private:
ExternalVector<uint8_t> _bytes;
+205 -93
View File
@@ -10,6 +10,7 @@
#include <algorithm>
#include <cstring>
#include <cstdio>
#include <new>
#if defined(PYXIS_NOMAD_MEMORY_DIAGNOSTIC)
#include <esp_heap_caps.h>
#endif
@@ -133,7 +134,15 @@ void NomadNetScreen::clear_document(){
}
_page.clear();
NomadNet::ExternalVector<LayoutFragment>().swap(_page_layout);
NomadNet::ExternalVector<LayoutFragment>().swap(_line_layout);
NomadNet::ExternalVector<LayoutCheckpoint>().swap(_layout_checkpoints);
NomadNet::ExternalVector<int32_t>().swap(_link_y);
NomadNet::ExternalVector<int32_t>().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<std::size_t>(_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<int32_t>(1,lv_obj_get_content_height(_content));
if(!layout_from(0,0,0,viewport*3,true))return false;
_physical_extent=std::min<int32_t>(_page_height,MAX_PHYSICAL_SCROLL_EXTENT);
_logical_scroll=0;
_layout_window_top=0;
_layout_window_bottom=std::min<int32_t>(_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<uint32_t>(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<uint16_t>(combined);
previous.width=static_cast<int16_t>(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<int16_t>(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<window_bottom){
for(auto fragment:_line_layout){
if(_page_layout.size()>=MAX_WINDOW_FRAGMENTS)return false;
if(shift>0)fragment.x=static_cast<int16_t>(fragment.x+shift);
const int32_t relative_y=line_y-window_top;
if(relative_y<INT16_MIN||relative_y>INT16_MAX)return false;
fragment.y=static_cast<int16_t>(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<uint16_t>(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<window_bottom){
if(_page_layout.size()>=MAX_WINDOW_FRAGMENTS)return false;
_page_layout.push_back(LayoutFragment(0,0,0,-1,0,
static_cast<int16_t>(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<end;++i)if(_page_layout[i].y==current_y)
line_width=std::max<int16_t>(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<end;++i)
if(_page_layout[i].y==current_y)_page_layout[i].x+=shift;
};
int32_t line_y=y;
_line_layout.clear();
for(uint16_t r=0;r<block.run_count&&block.first_run+r<_page.runs().size();++r){
if(_page_layout.size()>=content_fragment_limit){content_remaining=true;break;}
const uint16_t run_index=static_cast<uint16_t>(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<text.size()){
if(_page_layout.size()>=content_fragment_limit){content_remaining=true;break;}
std::size_t end=offset;
const bool whitespace=text[offset]==' '||text[offset]=='\t';
while(end<text.size()&&((text[end]==' '||text[end]=='\t')==whitespace)&&end-offset<255)++end;
@@ -416,8 +484,9 @@ bool NomadNetScreen::layout_page(){
static_cast<uint32_t>(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<uint16_t>(offset),
static_cast<uint16_t>(end-offset),run.link_index,x,y,fragment_w,height,false,
large));
if(run.link_index>=0&&static_cast<std::size_t>(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<uint16_t>(offset),
static_cast<uint16_t>(end-offset),run.link_index,x,0,fragment_w,height,false,
large)))return false;
x=static_cast<int16_t>(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<uint16_t>(_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<int16_t>(std::min<lv_coord_t>(304,
lv_txt_get_width(notice.data(),static_cast<uint32_t>(notice.size()),
&nomadnet_font_12,0,LV_TEXT_FLAG_NONE)));
_page_layout.push_back(LayoutFragment(notice_run_index,0,
static_cast<uint16_t>(notice.size()),-1,0,y,notice_width,19,false));
y+=22;
}
_page_height=std::max<int32_t>(y,lv_obj_get_content_height(_content));
if(build_index)_page_height=std::max<int32_t>(y,lv_obj_get_content_height(_content));
return true;
}
bool NomadNetScreen::layout_window(int32_t logical_scroll){
const int32_t viewport=std::max<int32_t>(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<int32_t>(1,lv_obj_get_content_height(_content));
const int32_t logical_max=std::max<int32_t>(0,_page_height-viewport);
const int32_t target=std::max<int32_t>(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<int16_t>(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<lv_coord_t>(left+fragment.x),draw_y,
lv_area_t area{static_cast<lv_coord_t>(left+fragment.x),static_cast<lv_coord_t>(draw_y),
static_cast<lv_coord_t>(left+fragment.x+std::max<int16_t>(fragment.width,1)-1),
static_cast<lv_coord_t>(draw_y+std::max<int16_t>(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<int16_t>(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<lv_coord_t>(left+span.x),draw_y,
lv_area_t area{static_cast<lv_coord_t>(left+span.x),static_cast<lv_coord_t>(draw_y),
static_cast<lv_coord_t>(left+span.x+std::max<int16_t>(span.width,1)-1),
static_cast<lv_coord_t>(draw_y+std::max<int16_t>(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<int>(_page.links().size());
int candidate=_selected_link;
for(int attempt=0;attempt<count;++attempt){
candidate=candidate<0?(direction>=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<std::size_t>(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(top<scroll)lv_obj_scroll_to_y(_content,top,LV_ANIM_ON);
else if(bottom>scroll+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<NomadNetScreen*>(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_point_t*>(lv_event_get_param(event));size->y=std::max<lv_coord_t>(size->y,self->_page_height);}
else if(code==LV_EVENT_GET_SELF_SIZE){auto* size=static_cast<lv_point_t*>(lv_event_get_param(event));size->y=std::max<lv_coord_t>(size->y,static_cast<lv_coord_t>(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_group_t*>(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.x+fragment.width&&y>=fragment.y&&y<fragment.y+fragment.height){self->_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.x+fragment.width&&
y>=fragment_y&&y<fragment_y+fragment.height){
self->_selected_link=fragment.link_index;self->activate_selected_link();break;
}
}
}
}
void NomadNetScreen::show(){
+31 -3
View File
@@ -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<LayoutFragment> _page_layout;
NomadNet::ExternalVector<LayoutFragment> _line_layout;
NomadNet::ExternalVector<LayoutCheckpoint> _layout_checkpoints;
NomadNet::ExternalVector<int32_t> _link_y;
NomadNet::ExternalVector<int32_t> _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<lv_obj_t*> _directory_focusables;
std::vector<std::string> _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();
@@ -0,0 +1,54 @@
#pragma once
#include <algorithm>
#include <cstdint>
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<int32_t>(0, logical_height - viewport_height);
const int32_t physical_max = std::max<int32_t>(0, physical_extent - viewport_height);
if (logical_max == 0 || physical_max == 0) return 0;
const int32_t clamped = std::max<int32_t>(0, std::min(physical_scroll, physical_max));
if (logical_max == physical_max) return clamped;
return static_cast<int32_t>((static_cast<int64_t>(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<int32_t>(0, logical_height - viewport_height);
const int32_t physical_max = std::max<int32_t>(0, physical_extent - viewport_height);
if (logical_max == 0 || physical_max == 0) return 0;
const int32_t clamped = std::max<int32_t>(0, std::min(logical_scroll, logical_max));
if (logical_max == physical_max) return clamped;
return static_cast<int32_t>((static_cast<int64_t>(clamped) * physical_max) / logical_max);
}
static int32_t window_top(int32_t logical_scroll, int32_t viewport_height) {
return std::max<int32_t>(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<int32_t>(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<uint32_t>(existing_offset) + existing_length == next_offset;
}
};
} // namespace UI::LXMF::NomadNet
+1
View File
@@ -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();
@@ -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<uint8_t> 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) ==
+28 -7
View File
@@ -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<LayoutCheckpoint> _layout_checkpoints" in screen_h
assert "_layout_window_top" in screen_h
assert "_logical_scroll" in screen_h
assert "ExternalVector<int32_t> _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<count;++attempt)" in screen
assert "fragment.link_index==candidate" in screen
assert "_link_y[candidate]>=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()