fix(ui): fix multi-level header suppression during scrolling
Implemented `show_sublabel` to ensure the first rendered entry in a scrolled viewport always shows its full group labels. Add regression tests for scrolling behavior in multi-level headers. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
This commit is contained in:
+57
-6
@@ -217,8 +217,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
let label = if layout.col_cats.is_empty() {
|
let label = if layout.col_cats.is_empty() {
|
||||||
layout.col_label(ci)
|
layout.col_label(ci)
|
||||||
} else {
|
} else {
|
||||||
let show = ci == 0 || data_col_items[ci][..=d] != data_col_items[ci - 1][..=d];
|
if show_sublabel(ci, col_offset, d, &data_col_items) {
|
||||||
if show {
|
|
||||||
data_col_items[ci][d].clone()
|
data_col_items[ci][d].clone()
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -340,9 +339,7 @@ impl<'a> GridWidget<'a> {
|
|||||||
let label = if layout.row_cats.is_empty() {
|
let label = if layout.row_cats.is_empty() {
|
||||||
layout.row_label(ri)
|
layout.row_label(ri)
|
||||||
} else {
|
} else {
|
||||||
let show =
|
if show_sublabel(ri, row_offset, d, &data_row_items) {
|
||||||
ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d];
|
|
||||||
if show {
|
|
||||||
data_row_items[ri][d].clone()
|
data_row_items[ri][d].clone()
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -649,6 +646,15 @@ pub fn compute_visible_cols(
|
|||||||
count.max(1)
|
count.max(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decide whether the multi-level header sub-label at depth `d` for the data
|
||||||
|
/// entry `idx` should be rendered, or blanked because it repeats the previous
|
||||||
|
/// visible entry's prefix. `first_rendered` is the index of the first entry
|
||||||
|
/// actually on screen (the row/col scroll offset): that entry always shows its
|
||||||
|
/// full labels so a scrolled viewport keeps its group context.
|
||||||
|
fn show_sublabel(idx: usize, first_rendered: usize, d: usize, items: &[&Vec<String>]) -> bool {
|
||||||
|
idx == first_rendered || items[idx][..=d] != items[idx - 1][..=d]
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export shared formatting functions
|
// Re-export shared formatting functions
|
||||||
pub use crate::format::{format_f64, parse_number_format};
|
pub use crate::format::{format_f64, parse_number_format};
|
||||||
|
|
||||||
@@ -678,7 +684,7 @@ fn truncate(s: &str, max_width: usize) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
||||||
|
|
||||||
use super::GridWidget;
|
use super::{show_sublabel, GridWidget};
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
@@ -1060,4 +1066,49 @@ mod tests {
|
|||||||
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
|
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
|
||||||
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
|
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Multi-level header repeat suppression ─────────────────────────────────
|
||||||
|
|
||||||
|
/// Items: [A,x], [A,y], [B,z] — multi-level header tuples.
|
||||||
|
fn sublabel_items() -> Vec<Vec<String>> {
|
||||||
|
[["A", "x"], ["A", "y"], ["B", "z"]]
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.iter().map(|s| s.to_string()).collect())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug (improvise-jjx): suppression compared against the entry at
|
||||||
|
/// `idx - 1` even when that entry is scrolled off-screen. The first
|
||||||
|
/// RENDERED entry must always show its full labels, otherwise scrolling
|
||||||
|
/// blanks the group label at the top of the viewport.
|
||||||
|
#[test]
|
||||||
|
fn first_rendered_entry_always_shows_sublabel_when_scrolled() {
|
||||||
|
let items = sublabel_items();
|
||||||
|
let refs: Vec<&Vec<String>> = items.iter().collect();
|
||||||
|
// Scrolled so index 1 ([A,y]) is the first visible entry. Its level-0
|
||||||
|
// prefix matches off-screen index 0 ([A,x]) — it must still show "A".
|
||||||
|
assert!(
|
||||||
|
show_sublabel(1, 1, 0, &refs),
|
||||||
|
"first rendered entry must show its label even when its prefix matches the off-screen entry above"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_prefix_below_first_rendered_entry_is_suppressed() {
|
||||||
|
let items = sublabel_items();
|
||||||
|
let refs: Vec<&Vec<String>> = items.iter().collect();
|
||||||
|
// Unscrolled: index 1 repeats index 0's level-0 prefix → suppressed.
|
||||||
|
assert!(!show_sublabel(1, 0, 0, &refs));
|
||||||
|
// Leaf level differs → shown.
|
||||||
|
assert!(show_sublabel(1, 0, 1, &refs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_prefix_is_always_shown() {
|
||||||
|
let items = sublabel_items();
|
||||||
|
let refs: Vec<&Vec<String>> = items.iter().collect();
|
||||||
|
assert!(show_sublabel(0, 0, 0, &refs));
|
||||||
|
assert!(show_sublabel(2, 0, 0, &refs)); // B != A
|
||||||
|
assert!(show_sublabel(2, 1, 0, &refs)); // scrolled, still differs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user