Skip to main content

dear_imgui_rs/
list_clipper.rs

1//! List clipper (virtualized lists)
2//!
3//! Wrapper around Dear ImGui's list clipper to efficiently display large
4//! lists by only processing visible items.
5//!
6use std::ops::Range;
7
8use crate::Ui;
9use crate::sys;
10
11mod registry;
12
13use registry::ClipperHandle;
14
15fn items_count_to_i32(items_count: usize, caller: &str) -> i32 {
16    i32::try_from(items_count)
17        .unwrap_or_else(|_| panic!("{caller} items_count exceeded Dear ImGui's i32 range"))
18}
19
20fn validate_items_height(items_height: f32, caller: &str) {
21    assert!(
22        items_height.is_finite(),
23        "{caller} items_height must be finite"
24    );
25    assert!(
26        items_height == -1.0 || items_height > 0.0,
27        "{caller} items_height must be -1.0 for automatic measurement or a positive value"
28    );
29}
30
31fn final_unknown_count_to_i32(items_count: usize, caller: &str) -> i32 {
32    assert!(
33        items_count <= i32::MAX as usize,
34        "{caller} final_items_count exceeded Dear ImGui's i32 range"
35    );
36    items_count as i32
37}
38
39fn display_index_from_i32(index: i32, caller: &str) -> usize {
40    assert!(index >= 0, "{caller} returned a negative display index");
41    usize::try_from(index).expect("non-negative display index must fit usize")
42}
43
44pub(crate) unsafe fn forget_context_clippers(context: *mut sys::ImGuiContext) -> usize {
45    unsafe { registry::forget_context(context) }
46}
47
48/// Used to render only the visible items when displaying a
49/// long list of items in a scrollable area.
50///
51/// For example, you can have a huge list of checkboxes.
52/// Without the clipper you have to call `ui.checkbox(...)`
53/// for every one, even if 99% of of them are not visible in
54/// the current frame. Using the `ListClipper`, you can only
55/// call `ui.checkbox(...)` for the currently visible items.
56///
57/// Note the efficiency of list clipper relies on the height
58/// of each item being cheaply calculated. The current rust
59/// bindings only works with a fixed height for all items.
60pub struct ListClipper {
61    items_count: usize,
62    items_height: f32,
63}
64
65impl ListClipper {
66    /// Begins configuring a list clipper.
67    pub const fn new(items_count: usize) -> Self {
68        ListClipper {
69            items_count,
70            items_height: -1.0,
71        }
72    }
73
74    /// Configure a clipper whose final item count is discovered during traversal.
75    pub const fn unknown_count() -> UnknownCountListClipper {
76        UnknownCountListClipper { items_height: -1.0 }
77    }
78
79    /// Manually set item height. If not set, the height of the first item is used for all subsequent rows.
80    pub const fn items_height(mut self, items_height: f32) -> Self {
81        self.items_height = items_height;
82        self
83    }
84
85    pub fn begin(self, ui: &Ui) -> ListClipperToken<'_> {
86        assert!(
87            self.items_count < i32::MAX as usize,
88            "ListClipper::begin() known items_count must be less than i32::MAX; use ListClipper::unknown_count() for the sentinel protocol"
89        );
90        validate_items_height(self.items_height, "ListClipper::begin()");
91        let items_count = items_count_to_i32(self.items_count, "ListClipper::begin()");
92        ListClipperToken::new(
93            ActiveListClipper::begin(ui, items_count, self.items_height, "ListClipper::begin()"),
94            self.items_count,
95        )
96    }
97}
98
99/// Builder for an unknown-count list clipper.
100pub struct UnknownCountListClipper {
101    items_height: f32,
102}
103
104impl UnknownCountListClipper {
105    /// Set a fixed item height. The first submitted item is measured when omitted.
106    pub const fn items_height(mut self, items_height: f32) -> Self {
107        self.items_height = items_height;
108        self
109    }
110
111    /// Begin unknown-count clipping.
112    pub fn begin(self, ui: &Ui) -> UnknownCountListClipperToken<'_> {
113        validate_items_height(self.items_height, "UnknownCountListClipper::begin()");
114        UnknownCountListClipperToken {
115            active: ActiveListClipper::begin(
116                ui,
117                i32::MAX,
118                self.items_height,
119                "UnknownCountListClipper::begin()",
120            ),
121        }
122    }
123}
124
125struct ActiveListClipper<'ui> {
126    ui: &'ui Ui,
127    handle: ClipperHandle,
128    stepped: bool,
129    ended: bool,
130    registered: bool,
131    retain_registration_after_exhaustion: bool,
132}
133
134impl<'ui> ActiveListClipper<'ui> {
135    fn begin(ui: &'ui Ui, items_count: i32, items_height: f32, caller: &str) -> Self {
136        ui.run_with_bound_context(|| unsafe {
137            registry::assert_can_begin(ui, caller);
138            let ptr = sys::ImGuiListClipper_ImGuiListClipper();
139            if ptr.is_null() {
140                panic!("ImGuiListClipper_ImGuiListClipper() returned null");
141            }
142            sys::ImGuiListClipper_Begin(ptr, items_count, items_height);
143            let handle = registry::register_current(ui, ptr, caller);
144            Self {
145                ui,
146                handle,
147                stepped: false,
148                ended: false,
149                registered: true,
150                retain_registration_after_exhaustion: items_count == i32::MAX,
151            }
152        })
153    }
154
155    fn include_items_by_index(&mut self, item_begin: i32, item_end: i32, caller: &str) {
156        self.ui.run_with_bound_context(|| unsafe {
157            let ptr = registry::assert_current(self.ui, self.handle, caller);
158            sys::ImGuiListClipper_IncludeItemsByIndex(ptr, item_begin, item_end);
159        });
160    }
161
162    fn step(&mut self, caller: &str) -> bool {
163        self.stepped = true;
164        let has_range = self.ui.run_with_bound_context(|| unsafe {
165            let ptr = registry::assert_current(self.ui, self.handle, caller);
166            sys::ImGuiListClipper_Step(ptr)
167        });
168        if !has_range {
169            self.ended = true;
170            if !self.retain_registration_after_exhaustion {
171                self.ui.run_with_bound_context(|| unsafe {
172                    registry::complete(self.ui, self.handle);
173                });
174                self.registered = false;
175            }
176        }
177        has_range
178    }
179
180    fn end(&mut self, caller: &str) {
181        if !self.ended {
182            self.ui.run_with_bound_context(|| unsafe {
183                let ptr = registry::assert_current(self.ui, self.handle, caller);
184                sys::ImGuiListClipper_End(ptr);
185            });
186            self.ended = true;
187            self.ui.run_with_bound_context(|| unsafe {
188                registry::complete(self.ui, self.handle);
189            });
190            self.registered = false;
191        }
192    }
193
194    fn display_start(&self, caller: &str) -> usize {
195        self.ui.run_with_bound_context(|| unsafe {
196            let ptr = registry::assert_current(self.ui, self.handle, caller);
197            display_index_from_i32((*ptr).DisplayStart, caller)
198        })
199    }
200
201    fn display_end(&self, caller: &str) -> usize {
202        self.ui.run_with_bound_context(|| unsafe {
203            let ptr = registry::assert_current(self.ui, self.handle, caller);
204            display_index_from_i32((*ptr).DisplayEnd, caller)
205        })
206    }
207}
208
209impl Drop for ActiveListClipper<'_> {
210    fn drop(&mut self) {
211        let binding = self.ui.ctx_binding.clone();
212        let _ = binding.try_with_bound_context(|| unsafe {
213            if self.registered {
214                registry::release(self.ui, self.handle);
215            } else {
216                sys::ImGuiListClipper_destroy(self.handle.ptr());
217            }
218        });
219    }
220}
221
222/// List clipper is a mechanism to efficiently implement scrolling of
223/// large lists with random access.
224///
225/// For example you have a list of 1 million buttons, and the list
226/// clipper will help you only draw the ones which are visible.
227///
228/// Nested clippers must be operated in LIFO order and from the same window and table scope where
229/// they began. Dropping tokens out of order is supported; native cleanup is deferred until all
230/// clippers above the dropped token have exited.
231pub struct ListClipperToken<'ui> {
232    active: ActiveListClipper<'ui>,
233    items_count: usize,
234}
235
236impl<'ui> ListClipperToken<'ui> {
237    fn new(active: ActiveListClipper<'ui>, items_count: usize) -> Self {
238        Self {
239            active,
240            items_count,
241        }
242    }
243
244    /// Keep one item from being clipped, regardless of its visibility.
245    ///
246    /// This must be called before the first [`Self::step`].
247    #[doc(alias = "IncludeItemByIndex")]
248    pub fn include_item_by_index(&mut self, item_index: usize) {
249        let item_end = item_index
250            .checked_add(1)
251            .expect("ListClipperToken::include_item_by_index() index overflowed");
252        self.include_items_by_index(item_index..item_end);
253    }
254
255    /// Keep a half-open item range from being clipped, regardless of visibility.
256    ///
257    /// This must be called before the first [`Self::step`]. Empty ranges are a no-op.
258    #[doc(alias = "IncludeItemsByIndex")]
259    pub fn include_items_by_index(&mut self, items: Range<usize>) {
260        assert!(
261            !self.active.ended && !self.active.stepped,
262            "ListClipperToken::include_items_by_index() must be called before the first step()"
263        );
264        assert!(
265            items.start <= items.end,
266            "ListClipperToken::include_items_by_index() range start must not exceed its end"
267        );
268        assert!(
269            items.end <= self.items_count,
270            "ListClipperToken::include_items_by_index() range exceeds the item count"
271        );
272        if items.is_empty() {
273            return;
274        }
275        let item_begin = items_count_to_i32(
276            items.start,
277            "ListClipperToken::include_items_by_index() range start",
278        );
279        let item_end = items_count_to_i32(
280            items.end,
281            "ListClipperToken::include_items_by_index() range end",
282        );
283        self.active.include_items_by_index(
284            item_begin,
285            item_end,
286            "ListClipperToken::include_items_by_index()",
287        );
288    }
289
290    /// Progress the list clipper.
291    ///
292    /// If this returns returns `true` then the you can loop between
293    /// between `clipper.display_range()`.
294    /// If this returns false, you must stop calling this method.
295    ///
296    /// Calling step again after it returns `false` will cause imgui
297    /// to abort. This mirrors the C++ interface.
298    ///
299    /// It is recommended to use the iterator interface!
300    pub fn step(&mut self) -> bool {
301        if self.active.ended {
302            panic!("ListClipperToken::step() called after the clipper has ended");
303        }
304        self.active.step("ListClipperToken::step()")
305    }
306
307    /// This is automatically called back the final call to
308    /// `step`. You can call it sooner but typically not needed.
309    pub fn end(&mut self) {
310        self.active.end("ListClipperToken::end()");
311    }
312
313    /// First item to call, updated each call to `step`
314    pub fn display_start(&self) -> usize {
315        self.active
316            .display_start("ListClipperToken::display_start()")
317    }
318
319    /// End of items to call (exclusive), updated each call to `step`
320    pub fn display_end(&self) -> usize {
321        self.active.display_end("ListClipperToken::display_end()")
322    }
323
324    /// Visible item range for the current step.
325    pub fn display_range(&self) -> Range<usize> {
326        self.display_start()..self.display_end()
327    }
328
329    /// Get an iterator which outputs all visible indexes. This is the
330    /// recommended way of using the clipper.
331    pub fn iter(self) -> ListClipperIterator<'ui> {
332        ListClipperIterator::new(self)
333    }
334}
335
336/// Active unknown-count list clipper.
337///
338/// Call [`Self::finish`] with the discovered item count so Dear ImGui can restore the final cursor
339/// position and scrollbar extent. Nested clippers follow the same LIFO and UI-scope rules as
340/// [`ListClipperToken`].
341#[must_use = "call finish(final_items_count) to finalize an unknown-count list"]
342pub struct UnknownCountListClipperToken<'ui> {
343    active: ActiveListClipper<'ui>,
344}
345
346impl UnknownCountListClipperToken<'_> {
347    /// Keep one index from being clipped. Call this before [`Self::next_range`].
348    #[doc(alias = "IncludeItemByIndex")]
349    pub fn include_item_by_index(&mut self, item_index: usize) {
350        let item_end = item_index
351            .checked_add(1)
352            .expect("UnknownCountListClipperToken::include_item_by_index() index overflowed");
353        self.include_items_by_index(item_index..item_end);
354    }
355
356    /// Keep a half-open index range from being clipped. Call this before [`Self::next_range`].
357    #[doc(alias = "IncludeItemsByIndex")]
358    pub fn include_items_by_index(&mut self, items: Range<usize>) {
359        assert!(
360            !self.active.ended && !self.active.stepped,
361            "UnknownCountListClipperToken::include_items_by_index() must be called before the first next_range()"
362        );
363        assert!(
364            items.start <= items.end,
365            "UnknownCountListClipperToken::include_items_by_index() range start must not exceed its end"
366        );
367        assert!(
368            items.end <= i32::MAX as usize,
369            "UnknownCountListClipperToken::include_items_by_index() range exceeded Dear ImGui's i32 range"
370        );
371        if items.is_empty() {
372            return;
373        }
374        let start = items_count_to_i32(
375            items.start,
376            "UnknownCountListClipperToken::include_items_by_index() range start",
377        );
378        let end = items_count_to_i32(
379            items.end,
380            "UnknownCountListClipperToken::include_items_by_index() range end",
381        );
382        self.active.include_items_by_index(
383            start,
384            end,
385            "UnknownCountListClipperToken::include_items_by_index()",
386        );
387    }
388
389    /// Advance to the next range that may contain visible items.
390    ///
391    /// Once exhausted, this method remains fused and returns `None` without entering FFI again.
392    pub fn next_range(&mut self) -> Option<Range<usize>> {
393        if self.active.ended {
394            return None;
395        }
396        let has_range = self
397            .active
398            .step("UnknownCountListClipperToken::next_range()");
399        if !has_range {
400            return None;
401        }
402        Some(
403            self.active
404                .display_start("UnknownCountListClipperToken::next_range() start")
405                ..self
406                    .active
407                    .display_end("UnknownCountListClipperToken::next_range() end"),
408        )
409    }
410
411    /// Finalize the list with its discovered item count.
412    ///
413    /// Automatic height requires one submitted measurement item followed by another
414    /// [`Self::next_range`] call before finishing a non-empty list. Finalization seeks directly;
415    /// it never drains unsubmitted ranges, which is important inside frozen table rows.
416    #[doc(alias = "SeekCursorForItem")]
417    pub fn finish(mut self, final_items_count: usize) {
418        let final_items_count =
419            final_unknown_count_to_i32(final_items_count, "UnknownCountListClipperToken::finish()");
420
421        let items_height = self.active.ui.run_with_bound_context(|| unsafe {
422            let ptr = registry::assert_current(
423                self.active.ui,
424                self.active.handle,
425                "UnknownCountListClipperToken::finish()",
426            );
427            (*ptr).ItemsHeight
428        });
429        if final_items_count == 0 && !(items_height.is_finite() && items_height > 0.0) {
430            self.active.end("UnknownCountListClipperToken::finish()");
431            return;
432        }
433        assert!(
434            items_height.is_finite() && items_height > 0.0,
435            "UnknownCountListClipperToken::finish() could not determine a positive item height"
436        );
437        self.active.ui.run_with_bound_context(|| unsafe {
438            let ptr = registry::assert_current(
439                self.active.ui,
440                self.active.handle,
441                "UnknownCountListClipperToken::finish()",
442            );
443            sys::ImGuiListClipper_SeekCursorForItem(ptr, final_items_count);
444        });
445        self.active.end("UnknownCountListClipperToken::finish()");
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    fn setup_context() -> crate::Context {
454        let mut ctx = crate::Context::create();
455        ctx.font_atlas()
456            .try_claim_legacy_renderer()
457            .expect("legacy renderer font atlas should be available")
458            .build();
459        ctx.io_mut().set_display_size([128.0, 128.0]);
460        ctx.io_mut().set_delta_time(1.0 / 60.0);
461        ctx
462    }
463
464    #[test]
465    fn step_after_end_panics_before_ffi() {
466        let mut ctx = setup_context();
467        let ui = ctx.frame();
468
469        ui.window("list_clipper_step_after_end").build(|| {
470            let mut clipper = ListClipper::new(0).begin(ui);
471            clipper.end();
472
473            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
474                let _ = clipper.step();
475            }));
476
477            assert!(result.is_err());
478        });
479    }
480
481    #[test]
482    fn end_after_step_false_is_a_noop() {
483        let mut ctx = setup_context();
484        let ui = ctx.frame();
485
486        ui.window("list_clipper_end_after_step_false").build(|| {
487            let mut clipper = ListClipper::new(0).begin(ui);
488            assert!(!clipper.step());
489            clipper.end();
490        });
491    }
492
493    #[test]
494    fn begin_rejects_invalid_inputs_before_ffi() {
495        let mut ctx = setup_context();
496        let ui = ctx.frame();
497
498        ui.window("list_clipper_invalid_inputs").build(|| {
499            assert!(
500                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
501                    let _clipper = ListClipper::new(usize::MAX).begin(ui);
502                }))
503                .is_err()
504            );
505
506            assert!(
507                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
508                    let _clipper = ListClipper::new(i32::MAX as usize).begin(ui);
509                }))
510                .is_err()
511            );
512
513            assert!(
514                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
515                    let _clipper = ListClipper::new(1usize).items_height(f32::NAN).begin(ui);
516                }))
517                .is_err()
518            );
519        });
520    }
521
522    #[test]
523    fn iterator_and_display_range_use_usize_indices() {
524        let mut ctx = setup_context();
525        ctx.io_mut().set_display_size([512.0, 512.0]);
526        let ui = ctx.frame();
527
528        ui.window("list_clipper_usize_indices")
529            .size([256.0, 256.0], crate::Condition::Always)
530            .build(|| {
531                let mut clipper = ListClipper::new(3usize).items_height(1.0).begin(ui);
532                while clipper.step() {
533                    for index in clipper.display_range() {
534                        let _: usize = index;
535                        ui.text(format!("row {index}"));
536                    }
537                }
538
539                let indices: Vec<usize> = ListClipper::new(3usize)
540                    .items_height(1.0)
541                    .begin(ui)
542                    .iter()
543                    .inspect(|index| ui.text(format!("row {index}")))
544                    .collect();
545                assert_eq!(indices, vec![0, 1, 2]);
546            });
547    }
548
549    #[test]
550    fn include_items_returns_non_visible_ranges_and_enforces_call_order() {
551        let mut ctx = setup_context();
552        let ui = ctx.frame();
553
554        ui.window("list_clipper_includes")
555            .size([96.0, 96.0], crate::Condition::Always)
556            .build(|| {
557                let mut clipper = ListClipper::new(1_000usize).items_height(16.0).begin(ui);
558                clipper.include_item_by_index(900);
559                clipper.include_items_by_index(950..953);
560
561                let mut included = [false; 4];
562                while clipper.step() {
563                    for index in clipper.display_range() {
564                        if index == 900 {
565                            included[0] = true;
566                        }
567                        if (950..953).contains(&index) {
568                            included[index - 949] = true;
569                        }
570                        ui.text(format!("row {index}"));
571                    }
572                }
573                assert!(included.into_iter().all(|seen| seen));
574
575                assert!(
576                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
577                        clipper.include_item_by_index(1);
578                    }))
579                    .is_err()
580                );
581            });
582    }
583
584    #[test]
585    fn include_items_rejects_invalid_ranges_before_ffi() {
586        let mut ctx = setup_context();
587        let ui = ctx.frame();
588
589        ui.window("list_clipper_invalid_includes").build(|| {
590            for (start, end) in [(5usize, 4usize), (0, 11)] {
591                let range = std::ops::Range { start, end };
592                let mut clipper = ListClipper::new(10usize).begin(ui);
593                assert!(
594                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
595                        clipper.include_items_by_index(range.clone());
596                    }))
597                    .is_err()
598                );
599            }
600        });
601    }
602
603    #[test]
604    fn unknown_count_finish_restores_the_final_cursor_position() {
605        let mut ctx = setup_context();
606        let ui = ctx.frame();
607
608        ui.window("unknown_count_cursor").build(|| {
609            let start_y = ui.cursor_screen_pos()[1];
610            let mut clipper = ListClipper::unknown_count().items_height(10.0).begin(ui);
611            assert!(clipper.next_range().is_some());
612            clipper.finish(5);
613            let end_y = ui.cursor_screen_pos()[1];
614            assert!((end_y - start_y - 50.0).abs() < 0.01);
615        });
616    }
617
618    #[test]
619    fn unknown_count_next_range_is_fused_after_exhaustion() {
620        let mut ctx = setup_context();
621        let ui = ctx.frame();
622
623        ui.window("unknown_count_fused").build(|| {
624            let mut clipper = ListClipper::unknown_count().items_height(10.0).begin(ui);
625            let mut steps = 0;
626            while clipper.next_range().is_some() {
627                steps += 1;
628                assert!(steps < 16, "unknown-count clipper did not exhaust");
629            }
630            assert!(clipper.next_range().is_none());
631            clipper.finish(3);
632        });
633    }
634
635    #[test]
636    fn unknown_count_empty_list_needs_no_measured_height() {
637        let mut ctx = setup_context();
638        let ui = ctx.frame();
639
640        ui.window("unknown_count_empty").build(|| {
641            ListClipper::unknown_count().begin(ui).finish(0);
642        });
643    }
644
645    #[test]
646    fn unknown_count_empty_list_restores_cursor_after_a_scrolled_seek() {
647        let mut ctx = setup_context();
648        {
649            let ui = ctx.frame();
650            ui.window("unknown_count_empty_scrolled")
651                .size([96.0, 96.0], crate::Condition::Always)
652                .build(|| {
653                    ui.dummy([1.0, 2_000.0]);
654                    ui.set_scroll_y(500.0);
655                });
656        }
657        let _ = ctx.render_legacy();
658
659        let ui = ctx.frame();
660        ui.window("unknown_count_empty_scrolled")
661            .size([96.0, 96.0], crate::Condition::Always)
662            .build(|| {
663                assert!(ui.scroll_y() > 0.0);
664                let start_y = ui.cursor_screen_pos()[1];
665                let mut clipper = ListClipper::unknown_count().items_height(10.0).begin(ui);
666                let first_range = clipper
667                    .next_range()
668                    .expect("a fixed-height sentinel list should produce a visible range");
669                assert!(first_range.start > 0);
670                clipper.finish(0);
671                let end_y = ui.cursor_screen_pos()[1];
672                assert!((end_y - start_y).abs() < 0.01);
673            });
674    }
675
676    #[test]
677    fn unknown_count_finish_does_not_drain_frozen_table_rows() {
678        let mut ctx = setup_context();
679        let ui = ctx.frame();
680
681        ui.window("unknown_count_frozen_table_window").build(|| {
682            ui.table("unknown_count_frozen_table")
683                .flags(crate::TableFlags::SCROLL_Y)
684                .outer_size([96.0, 64.0])
685                .freeze(0, 1)
686                .column("value")
687                .done()
688                .build(|ui| {
689                    ListClipper::unknown_count()
690                        .items_height(10.0)
691                        .begin(ui)
692                        .finish(0);
693                });
694        });
695    }
696
697    #[test]
698    fn unknown_count_auto_height_measures_the_first_item() {
699        let mut ctx = setup_context();
700        let ui = ctx.frame();
701
702        ui.window("unknown_count_auto_height").build(|| {
703            let mut clipper = ListClipper::unknown_count().begin(ui);
704            assert_eq!(clipper.next_range(), Some(0..1));
705            ui.dummy([1.0, 10.0]);
706
707            let visible = clipper
708                .next_range()
709                .expect("measuring the first item should produce the visible range");
710            let raw = clipper.active.handle.ptr();
711            let measured_height = unsafe { (*raw).ItemsHeight };
712            assert!(measured_height.is_finite() && measured_height > 0.0);
713            for index in visible {
714                if index >= 5 {
715                    break;
716                }
717                ui.dummy([1.0, 10.0]);
718            }
719            let expected_y = unsafe {
720                ((*raw).StartPosY + (*raw).StartSeekOffsetY + 5.0 * f64::from(measured_height))
721                    as f32
722            };
723
724            clipper.finish(5);
725            assert!((ui.cursor_screen_pos()[1] - expected_y).abs() < 0.01);
726        });
727    }
728
729    #[test]
730    fn out_of_order_drop_defers_native_cleanup_until_lifo_is_restored() {
731        let mut ctx = setup_context();
732        let context = ctx.as_raw();
733        let ui = ctx.frame();
734
735        ui.window("list_clipper_out_of_order_drop").build(|| {
736            let outer = ListClipper::new(4).items_height(10.0).begin(ui);
737            let inner = ListClipper::unknown_count().items_height(10.0).begin(ui);
738            assert_eq!(registry::active_count(context), 2);
739
740            drop(outer);
741            assert_eq!(registry::active_count(context), 2);
742
743            drop(inner);
744            assert_eq!(registry::active_count(context), 0);
745
746            let mut next = ListClipper::new(1).items_height(10.0).begin(ui);
747            assert!(next.step());
748            ui.dummy([1.0, 10.0]);
749            assert!(!next.step());
750        });
751    }
752
753    #[test]
754    fn clipper_drop_does_not_double_panic_during_native_scope_recovery() {
755        let mut ctx = setup_context();
756        let context = ctx.as_raw();
757        let ui = ctx.frame();
758
759        ui.window("list_clipper_scope_recovery").build(|| {
760            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
761                let outer = ui.push_id("outer");
762                let _inner = ui.push_id("inner");
763                let _clipper = ListClipper::new(1).items_height(10.0).begin(ui);
764
765                drop(outer);
766            }));
767
768            assert!(result.is_err());
769            assert_eq!(registry::active_count(context), 0);
770
771            let mut next = ListClipper::new(1).items_height(10.0).begin(ui);
772            assert!(next.step());
773            ui.dummy([1.0, 10.0]);
774            assert!(!next.step());
775        });
776    }
777
778    #[test]
779    fn wrong_scope_drop_uses_layout_neutral_cleanup() {
780        let mut ctx = setup_context();
781        let context = ctx.as_raw();
782        let ui = ctx.frame();
783
784        ui.window("list_clipper_drop_scope_owner").build(|| {
785            let mut clipper = Some(ListClipper::new(1).items_height(10.0).begin(ui));
786            ui.window("list_clipper_drop_scope_other").build(|| {
787                drop(clipper.take());
788            });
789            assert_eq!(registry::active_count(context), 0);
790
791            let mut next = ListClipper::new(1).items_height(10.0).begin(ui);
792            assert!(next.step());
793            ui.dummy([1.0, 10.0]);
794            assert!(!next.step());
795        });
796    }
797
798    #[test]
799    fn clipper_dropped_after_its_window_does_not_poison_the_frame() {
800        let mut ctx = setup_context();
801        let context = ctx.as_raw();
802        let ui = ctx.frame();
803        let mut clipper = None;
804
805        ui.window("list_clipper_late_drop_owner").build(|| {
806            clipper = Some(ListClipper::new(1).items_height(10.0).begin(ui));
807        });
808        drop(clipper.take());
809        drop(clipper);
810        assert_eq!(registry::active_count(context), 0);
811        assert!(ctx.render_legacy().valid());
812    }
813
814    #[test]
815    fn forgotten_clipper_rejects_only_the_current_frame() {
816        let mut ctx = setup_context();
817        let context = ctx.as_raw();
818        let ui = ctx.frame();
819        let clipper = ui
820            .window("list_clipper_forgotten")
821            .build(|| ListClipper::new(1).items_height(10.0).begin(ui))
822            .expect("test window should be visible");
823        std::mem::forget(clipper);
824        assert_eq!(registry::active_count(context), 1);
825
826        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
827            let _ = ctx.render_legacy();
828        }));
829        assert!(result.is_err());
830        assert_eq!(registry::active_count(context), 0);
831        assert_eq!(
832            ctx.frame_lifecycle_state(),
833            crate::FrameLifecycleState::Idle
834        );
835
836        let ui = ctx.frame();
837        ui.window("list_clipper_recovered_frame").build(|| {
838            let mut next = ListClipper::new(1).items_height(10.0).begin(ui);
839            assert!(next.step());
840            ui.dummy([1.0, 10.0]);
841            assert!(!next.step());
842        });
843        assert!(ctx.render_legacy().valid());
844    }
845
846    #[test]
847    fn reopening_the_same_window_does_not_reuse_the_old_clipper_scope() {
848        let mut ctx = setup_context();
849        let context = ctx.as_raw();
850        let ui = ctx.frame();
851        let mut escaped = ui
852            .window("list_clipper_reopened_scope")
853            .build(|| ListClipper::new(1).items_height(10.0).begin(ui))
854            .expect("test window should be visible");
855        assert_eq!(registry::active_count(context), 1);
856
857        ui.window("list_clipper_reopened_scope").build(|| {
858            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
859                let _ = escaped.step();
860            }));
861            assert!(result.is_err());
862            drop(escaped);
863            assert_eq!(registry::active_count(context), 0);
864
865            drop(ListClipper::new(0).items_height(10.0).begin(ui));
866        });
867
868        assert_eq!(registry::active_count(context), 0);
869        assert!(ctx.render_legacy().valid());
870    }
871
872    #[test]
873    fn internal_window_reentry_preserves_the_active_clipper_scope() {
874        let mut ctx = setup_context();
875        let flags = ctx.io().config_flags() | crate::ConfigFlags::DOCKING_ENABLE;
876        ctx.io_mut().set_config_flags(flags);
877        let ui = ctx.frame();
878        let host_name = format!("WindowOverViewport_{:08X}", ui.main_viewport().id().raw());
879
880        ui.window(host_name).build(|| {
881            let mut clipper = ListClipper::new(1).items_height(10.0).begin(ui);
882
883            let _ = ui.dock_space_over_main_viewport_raw(0.into(), crate::DockNodeFlags::NONE);
884
885            clipper.include_item_by_index(0);
886            assert!(clipper.step());
887            ui.dummy([1.0, 10.0]);
888            assert!(!clipper.step());
889        });
890
891        assert!(ctx.render_legacy().valid());
892    }
893
894    #[test]
895    fn reopening_the_same_table_does_not_reuse_the_old_clipper_scope() {
896        let mut ctx = setup_context();
897        let context = ctx.as_raw();
898        let ui = ctx.frame();
899
900        ui.window("list_clipper_reopened_table_window").build(|| {
901            let mut escaped = {
902                let Some(_table) = ui.begin_table("list_clipper_reopened_table", 1) else {
903                    panic!("test table should be visible");
904                };
905                ListClipper::new(1).items_height(10.0).begin(ui)
906            };
907            assert_eq!(registry::active_count(context), 1);
908
909            let Some(_table) = ui.begin_table("list_clipper_reopened_table", 1) else {
910                panic!("test table should be visible");
911            };
912            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
913                let _ = escaped.step();
914            }));
915            assert!(result.is_err());
916            drop(escaped);
917            assert_eq!(registry::active_count(context), 0);
918        });
919
920        assert!(ctx.render_legacy().valid());
921    }
922
923    #[test]
924    fn nested_clipper_begin_requires_the_owner_window_and_table_scope() {
925        let mut ctx = setup_context();
926        let ui = ctx.frame();
927
928        ui.window("list_clipper_begin_scope_owner").build(|| {
929            let mut outer = ListClipper::new(1).items_height(10.0).begin(ui);
930
931            ui.window("list_clipper_begin_scope_other").build(|| {
932                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
933                    let _ = ListClipper::new(1).items_height(10.0).begin(ui);
934                }));
935                assert!(result.is_err());
936            });
937
938            {
939                let Some(_table) = ui.begin_table("list_clipper_begin_scope_table", 1) else {
940                    panic!("test table should be visible");
941                };
942                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
943                    let _ = ListClipper::new(1).items_height(10.0).begin(ui);
944                }));
945                assert!(result.is_err());
946            }
947
948            assert!(outer.step());
949            ui.dummy([1.0, 10.0]);
950            assert!(!outer.step());
951        });
952    }
953
954    #[test]
955    fn nested_clipper_operations_require_lifo_order() {
956        let mut ctx = setup_context();
957        let ui = ctx.frame();
958
959        ui.window("list_clipper_lifo_operations").build(|| {
960            let mut outer = ListClipper::new(1).items_height(10.0).begin(ui);
961            let inner = ListClipper::new(1).items_height(10.0).begin(ui);
962
963            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
964                let _ = outer.step();
965            }));
966            assert!(result.is_err());
967
968            drop(inner);
969            assert!(outer.step());
970            ui.dummy([1.0, 10.0]);
971            assert!(!outer.step());
972        });
973    }
974
975    #[test]
976    fn completed_inner_clipper_releases_the_native_lifo_scope_before_drop() {
977        let mut ctx = setup_context();
978        let context = ctx.as_raw();
979        let ui = ctx.frame();
980
981        ui.window("list_clipper_completed_inner").build(|| {
982            let mut outer = ListClipper::new(1).items_height(10.0).begin(ui);
983            let mut inner = ListClipper::new(1).items_height(10.0).begin(ui);
984
985            assert!(inner.step());
986            ui.dummy([1.0, 10.0]);
987            assert!(!inner.step());
988            assert_eq!(registry::active_count(context), 1);
989
990            assert!(outer.step());
991            ui.dummy([1.0, 10.0]);
992            assert!(!outer.step());
993            assert_eq!(registry::active_count(context), 0);
994
995            drop(inner);
996        });
997    }
998
999    #[test]
1000    fn explicitly_ended_token_does_not_block_a_different_window_scope() {
1001        let mut ctx = setup_context();
1002        let context = ctx.as_raw();
1003        let ui = ctx.frame();
1004        let ended = ui
1005            .window("list_clipper_explicit_end_owner")
1006            .build(|| {
1007                let mut clipper = ListClipper::new(1).items_height(10.0).begin(ui);
1008                clipper.end();
1009                clipper
1010            })
1011            .expect("test window should be visible");
1012        assert_eq!(registry::active_count(context), 0);
1013
1014        ui.window("list_clipper_after_explicit_end").build(|| {
1015            let mut next = ListClipper::new(1).items_height(10.0).begin(ui);
1016            assert!(next.step());
1017            ui.dummy([1.0, 10.0]);
1018            assert!(!next.step());
1019        });
1020
1021        assert!(
1022            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1023                let _ = ended.display_range();
1024            }))
1025            .is_err()
1026        );
1027        drop(ended);
1028    }
1029
1030    #[test]
1031    fn clipper_operations_require_the_begin_window_and_table_scope() {
1032        let mut ctx = setup_context();
1033        let ui = ctx.frame();
1034
1035        ui.window("list_clipper_scope_owner").build(|| {
1036            let mut clipper = ListClipper::new(1).items_height(10.0).begin(ui);
1037
1038            ui.window("list_clipper_other_window").build(|| {
1039                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1040                    let _ = clipper.step();
1041                }));
1042                assert!(result.is_err());
1043            });
1044
1045            {
1046                let Some(_table) = ui.begin_table("list_clipper_other_table", 1) else {
1047                    panic!("test table should be visible");
1048                };
1049                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1050                    let _ = clipper.step();
1051                }));
1052                assert!(result.is_err());
1053            }
1054
1055            clipper.end();
1056        });
1057    }
1058
1059    #[test]
1060    fn unknown_count_validates_final_count_before_ffi() {
1061        assert_eq!(
1062            final_unknown_count_to_i32(
1063                i32::MAX as usize,
1064                "unknown_count_validates_final_count_before_ffi"
1065            ),
1066            i32::MAX
1067        );
1068
1069        let mut ctx = setup_context();
1070        let ui = ctx.frame();
1071        ui.window("unknown_count_overflow").build(|| {
1072            let clipper = ListClipper::unknown_count().items_height(10.0).begin(ui);
1073            assert!(
1074                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1075                    clipper.finish(i32::MAX as usize + 1);
1076                }))
1077                .is_err()
1078            );
1079        });
1080    }
1081}
1082
1083pub struct ListClipperIterator<'ui> {
1084    list_clipper: ListClipperToken<'ui>,
1085    exhausted: bool,
1086    last_value: Option<usize>,
1087}
1088
1089impl<'ui> ListClipperIterator<'ui> {
1090    fn new(list_clipper: ListClipperToken<'ui>) -> Self {
1091        Self {
1092            list_clipper,
1093            exhausted: false,
1094            last_value: None,
1095        }
1096    }
1097}
1098
1099impl Iterator for ListClipperIterator<'_> {
1100    type Item = usize;
1101
1102    fn next(&mut self) -> Option<Self::Item> {
1103        loop {
1104            if let Some(value) = self.last_value {
1105                let next_value = value + 1;
1106
1107                if next_value >= self.list_clipper.display_end() {
1108                    self.last_value = None;
1109                } else {
1110                    self.last_value = Some(next_value);
1111                }
1112
1113                return Some(value);
1114            }
1115
1116            if self.exhausted {
1117                // If the clipper is exhausted, don't call step again!
1118                return None;
1119            }
1120
1121            // Advance the clipper
1122            let ret = self.list_clipper.step();
1123            if !ret {
1124                self.exhausted = true;
1125                return None;
1126            }
1127
1128            // Setup iteration for this step's chunk
1129            let start = self.list_clipper.display_start();
1130            let end = self.list_clipper.display_end();
1131
1132            if start < end {
1133                let next_value = start + 1;
1134                if next_value < end {
1135                    self.last_value = Some(next_value);
1136                }
1137                return Some(start);
1138            } else {
1139                self.last_value = None;
1140            }
1141        }
1142    }
1143}