diff --git a/libs/ui/FreeInkUI/include/components/lists/list.h b/libs/ui/FreeInkUI/include/components/lists/list.h index ac852e33..3ea8495e 100644 --- a/libs/ui/FreeInkUI/include/components/lists/list.h +++ b/libs/ui/FreeInkUI/include/components/lists/list.h @@ -21,6 +21,10 @@ struct ListItem { // value string is ignored when set. Activation stays row-level via action. bool toggle = false; bool toggleChecked = false; + // Optional section heading drawn immediately before this selectable row. + // It shares the row's logical index and interaction value. Kept last so + // existing aggregate initializers remain source-compatible. + const char *sectionHeading = nullptr; }; struct ListNav; @@ -41,7 +45,9 @@ struct ListProps { // pin tens of KB of ListItems + label strings for rows that are never // drawn. list() only touches indexes in [topIndex, topIndex + visible], // so the caller must keep the window covering that range (refresh it after - // viewport changes, before list()). 0 = items is the full array. + // viewport changes, before list()). Set props.nav for a window whose top may + // enter the last fixed-height page; otherwise list() may clamp top below the + // supplied window. 0 = items is the full array. uint16_t itemsWindowFirst = 0; // Number of ListItems supplied in `items` when it is a virtual window. // Set this whenever itemsWindowFirst is non-zero (or the supplied array is @@ -235,6 +241,8 @@ struct ListNav { } // Pull the viewport the minimal amount so the selection is visible. + // selected and top must both use absolute row indexes; callers that keep a + // focus sentinel in selected must translate before calling follow(). void follow(const int count) { followPending = true; // confirmed (or corrected) by onListRendered() const uint16_t rows = @@ -453,8 +461,26 @@ void list(Frame &frame, Rect rect, const ListProps &props) { } else if (labelLines > 1) { itemH = static_cast(rowH + labelLh * (labelLines - 1)); } - if (static_cast(cursorY + itemH) > rowArea.bottom()) + const bool hasSectionHeading = item.sectionHeading != nullptr && item.sectionHeading[0] != '\0'; + const int16_t sectionPad = hasSectionHeading && i != top ? props.sectionGap : 0; + const int16_t sectionH = + hasSectionHeading ? static_cast(sectionPad + headerH + rowGap) : 0; + if (static_cast(cursorY + sectionH + itemH) > rowArea.bottom()) break; + if (hasSectionHeading) { + cursorY = static_cast(cursorY + sectionPad); + Rect headerRow{static_cast(rowArea.x + sidePad), cursorY, + static_cast(rowArea.width - sidePad * 2), + headerLh}; + frame.target().text(headerRow, item.sectionHeading, props.headerText); + if (props.headerUnderline) { + frame.target().fill(Rect{headerRow.x, + static_cast(cursorY + headerLh + 2), + headerRow.width, 1}, + Paint::solid(props.headerText.color)); + } + cursorY = static_cast(cursorY + headerH + rowGap); + } ++drawnRows; ++consumedIndexes; if (props.selectedIndex == static_cast(i)) diff --git a/libs/ui/FreeInkUI/test/host/test_freeinkui.cpp b/libs/ui/FreeInkUI/test/host/test_freeinkui.cpp index b0bf606b..96f6fca3 100644 --- a/libs/ui/FreeInkUI/test/host/test_freeinkui.cpp +++ b/libs/ui/FreeInkUI/test/host/test_freeinkui.cpp @@ -785,6 +785,69 @@ void testListItemsWindowSkipsUnavailablePartialPreview() { CHECK(!draw.drewForbiddenLabel); } +void testListInlineSectionHeadingWindow() { + FakeDrawTarget draw; + DeviceContext device = makeDevice(); + InputSnapshot input; + InteractionBuffer<16> interactions; + Frame<16> frame(draw, device, input, interactions); + + ListItem window[3]{}; + window[0].label = "Book A"; + window[0].sectionHeading = "Author A"; + window[0].actionValue = 10; + window[1].label = "Book B"; + window[1].actionValue = 11; + window[2].label = "Book C"; + window[2].sectionHeading = "Author C"; + window[2].actionValue = 12; + + ListNav nav; + ListProps props; + props.items = window; + props.itemsWindowFirst = 10; + props.itemsWindowCount = 3; + props.count = 100; + props.topIndex = 10; + props.action = 9; + props.rowHeight = 40; + props.headerUnderline = false; + props.nav = &nav; + list(frame, Rect{0, 0, 480, 96}, props); // 16px heading + two 40px rows + + CHECK_EQ(interactions.count(), 2u); + CHECK_EQ(interactions.data()[0].value, 10); + CHECK_EQ(interactions.data()[1].value, 11); + CHECK_EQ(nav.drawnRows, 2); + CHECK_EQ(draw.countKind(FakeDrawTarget::Op::Text), 3u); // heading + two books +} + +void testListInlineSectionHeadingDoesNotOrphan() { + FakeDrawTarget draw; + DeviceContext device = makeDevice(); + InputSnapshot input; + InteractionBuffer<4> interactions; + Frame<4> frame(draw, device, input, interactions); + + ListItem item{}; + item.label = "Book"; + item.sectionHeading = "Author"; + item.actionValue = 4; + + ListNav nav; + ListProps props; + props.items = &item; + props.count = 1; + props.action = 9; + props.rowHeight = 40; + props.nav = &nav; + list(frame, Rect{0, 0, 480, 55}, props); // heading + row need 56px + + CHECK_EQ(interactions.count(), 0u); + CHECK_EQ(nav.drawnRows, 0); + CHECK_EQ(draw.countKind(FakeDrawTarget::Op::Text), 0u); +} + void testListNavLayoutFeedback() { FakeDrawTarget draw; DeviceContext device = makeDevice(); @@ -3406,6 +3469,8 @@ int main() { testListItemsWindow(); testListItemsWindowStopsBeforePastEndMeasurement(); testListItemsWindowSkipsUnavailablePartialPreview(); + testListInlineSectionHeadingWindow(); + testListInlineSectionHeadingDoesNotOrphan(); testListNavLayoutFeedback(); testListNavConvergesThroughRealList(); testListCanUseFullTitleWidthWithShortValue();