- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them - setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back - unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used - the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
454 lines
21 KiB
Rust
454 lines
21 KiB
Rust
//! The Text-arm rasterization: one `QuadContent::Text` quad — a region FontString, a
|
||
//! message-frame ring line, or the focused editbox's windowed text — into glyph [`UiQuad`]s:
|
||
//! the editbox window + selection/caret, the ellipsis-truncate seam, the drop shadow, and the
|
||
//! hyperlink span collection. Split out of the extraction pass ([`super`]) when it crossed the
|
||
//! size budget; the arm's host-loop context arrives bundled as [`TextHost`].
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_ui::order::ZTarget;
|
||
use benilla_ui::script::{EditBoxTextUi, FontShadow, JustifyH, JustifyV, Outline};
|
||
use benilla_ui::widget::FrameHandle;
|
||
|
||
use crate::ui_pass::{UiQuad, UvRect};
|
||
use crate::ui_text::{layout_text_quads, layout_text_quads_links, UiFontAtlas};
|
||
|
||
/// The `QuadContent::Text` payload minus the text itself — the region's resolved style, passed
|
||
/// verbatim from the destructure at the [`super::drive_script`] call site.
|
||
pub(super) struct TextStyle {
|
||
pub color: Option<[f32; 4]>,
|
||
pub justify_h: JustifyH,
|
||
pub justify_v: JustifyV,
|
||
pub font: Option<String>,
|
||
pub font_height: Option<f32>,
|
||
pub text_height: Option<f32>,
|
||
pub shadow: Option<FontShadow>,
|
||
pub outline: Outline,
|
||
pub alpha_gradient: Option<(f32, f32)>,
|
||
}
|
||
|
||
/// The host-loop context one Text quad draws under: the extracted quad's identity (z/alpha/
|
||
/// target), its y-flipped rect + clip, the focused editbox's text-UI geometry (matched to this
|
||
/// quad inside), and the screen height for the link-span flip back to engine space.
|
||
pub(super) struct TextHost<'a> {
|
||
pub z: u64,
|
||
pub alpha: f32,
|
||
pub target: ZTarget,
|
||
pub rect: Rect,
|
||
pub clip: Option<Rect>,
|
||
/// The focused editbox's text-UI geometry, unfiltered — [`emit`] matches it to this quad
|
||
/// by [`EditBoxTextUi::target`].
|
||
pub ebox: Option<&'a EditBoxTextUi>,
|
||
pub screen_h: f32,
|
||
/// The 768-virtual scale `s = windowH/768` (decision 0582): rects arrive pre-scaled (px);
|
||
/// this converts the remaining unit-space inputs — font heights, shadow offsets, the
|
||
/// engine's editbox caret/selection x-offsets — into the same px space.
|
||
pub scale: f32,
|
||
/// The owning frame's `effective_scale` ([`benilla_ui::script::ExtractedQuad::scale`]): the
|
||
/// rect already carries it; the FONT metrics — glyph raster size and shadow offset, both
|
||
/// frame-local — multiply by it here (the real client's text rides `SetScale`; 0219 §2's
|
||
/// divergence, closed). NOT applied to the editbox caret/selection/advance x-offsets: those
|
||
/// arrive in screen UI units (the advance measure already rode the scale — see
|
||
/// [`benilla_ui::script::EditBoxAdvanceRequest`]'s `scale` doc).
|
||
pub font_scale: f32,
|
||
/// Captures pin the caret blink ON (deterministic pixels); live, the engine's phase decides.
|
||
pub caret_pinned: bool,
|
||
}
|
||
|
||
/// Rasterize one Text quad into `out` (and its hyperlink spans into `link_spans`, message-frame
|
||
/// lines only). The glyph quads share the owning region's `z` — see [`layout_text_quads`]'s doc
|
||
/// for why that's already the correct total-order slot.
|
||
pub(super) fn emit(
|
||
atlas: &mut UiFontAtlas,
|
||
text: &str,
|
||
style: TextStyle,
|
||
host: TextHost<'_>,
|
||
out: &mut Vec<UiQuad>,
|
||
link_spans: &mut Vec<(FrameHandle, benilla_ui::layout::Rect, String, String)>,
|
||
) {
|
||
let base_color = style.color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||
let spec = crate::ui_text::FontSpec {
|
||
path: style.font.as_deref(),
|
||
// The drawn px under the two size regimes × the 768-virtual scale × the owner's frame
|
||
// scale (`drawn_px`, decision 0582): one-to-one text unit-caps at 32 (a frame-LOCAL cap,
|
||
// like the seam scale it precedes) then scales; a SetTextHeight override scales uncapped.
|
||
// The shadow twin below inherits the spec (`..spec`).
|
||
height: crate::ui_text::drawn_px(
|
||
style.font_height,
|
||
style.text_height,
|
||
host.scale * host.font_scale,
|
||
),
|
||
outline: style.outline,
|
||
paint_halo: true,
|
||
alpha_gradient: style.alpha_gradient,
|
||
};
|
||
// The focused edit box's text draws WINDOWED (`0x77da80`): the scroll window's substring,
|
||
// left-anchored at the line origin in an unbounded rect (the window never wraps — the box
|
||
// edge clips instead), the selection highlight behind everything, the white caret after.
|
||
// `line_origin` gives the origin + the line cell; the x-offsets inside it are the engine's
|
||
// advance-derived geometry.
|
||
let ebox = host.ebox.filter(|u| u.target == host.target);
|
||
let mut draw_text: &str = text;
|
||
let mut draw_rect = host.rect;
|
||
let mut draw_justify = crate::ui_text::Justify {
|
||
h: style.justify_h,
|
||
v: style.justify_v,
|
||
};
|
||
let mut text_clip = host.clip;
|
||
// The shadow's whole-pixel displacement ([`shadow_offset_px`]), hoisted: the band scissor
|
||
// below has to admit the ink it puts under the fill.
|
||
let shadow_delta = style.shadow.map(|sh| {
|
||
(
|
||
shadow_offset_px(sh.offset[0] * host.scale * host.font_scale),
|
||
shadow_offset_px(-sh.offset[1] * host.scale * host.font_scale),
|
||
)
|
||
});
|
||
// A message-frame ring line's scissor follows the seat, at the bottom edge only.
|
||
//
|
||
// The band ladder stacks UP from the frame's bottom edge, so the newest line is flush with it
|
||
// by construction and can never overflow there — the scissor's real job is the OTHER end, the
|
||
// half-fitting scrollback line at the frame's top, and that edge is untouched. What the bottom
|
||
// edge did cut was ink the renderer had deliberately placed below the band: the seat nudge
|
||
// (0351) lowers every UI text block one px, and the drop shadow sits one further px under the
|
||
// fill. Two of our own dials against a scissor that predates them — and the newest chat line
|
||
// lost the tails of its descenders and the feet of its brackets (director, 2026-07-26).
|
||
if matches!(host.target, ZTarget::Frame(_)) {
|
||
if let Some(c) = text_clip.as_mut() {
|
||
c.max.y += band_clip_slack(shadow_delta.map(|(_, dy)| dy));
|
||
}
|
||
}
|
||
let mut ebox_geom = None;
|
||
if let Some(ui) = ebox {
|
||
let drawn = &text[ui.display_from.min(text.len())..];
|
||
let (x0, top, cell_h) =
|
||
crate::ui_text::line_origin(atlas, drawn, host.rect, draw_justify, spec);
|
||
ebox_geom = Some((x0, top, cell_h));
|
||
text_clip = Some(host.clip.map_or(host.rect, |c| c.intersect(host.rect)));
|
||
if !ui.multi_line {
|
||
// Single-line: the windowed draw — the scroll window's substring, left-anchored in
|
||
// an unbounded rect (the box edge clips instead of wrapping).
|
||
draw_text = drawn;
|
||
draw_rect = Rect::new(x0, host.rect.min.y, x0 + 100_000.0, host.rect.max.y);
|
||
draw_justify = crate::ui_text::Justify {
|
||
h: JustifyH::Left,
|
||
v: style.justify_v,
|
||
};
|
||
}
|
||
// Multiline draws the whole block through the ordinary wrapped path below (the text
|
||
// region's justify is already the editbox law: TOP/LEFT) — only the caret/selection are
|
||
// seated by `(row, x)` at the same row pitch the wrap answered.
|
||
for &(row, sx0, sx1) in &ui.selection {
|
||
let hc = ui.highlight_color;
|
||
#[allow(clippy::cast_precision_loss)]
|
||
let ry = top + row as f32 * cell_h;
|
||
// The engine's selection x-span is in UI units (its advance table was fed ÷scale).
|
||
out.push(UiQuad {
|
||
rect: Rect::new(
|
||
x0 + sx0 * host.scale,
|
||
ry,
|
||
x0 + sx1 * host.scale,
|
||
ry + cell_h,
|
||
),
|
||
z_key: host.z,
|
||
texture: None,
|
||
uv: UvRect::FULL,
|
||
color: [hc[0], hc[1], hc[2], hc[3] * host.alpha],
|
||
additive: false,
|
||
circular: false,
|
||
desaturated: false,
|
||
clip: text_clip,
|
||
rotation: 0.0,
|
||
mask: None,
|
||
corners: None,
|
||
});
|
||
}
|
||
}
|
||
// The height-gated ellipsis-truncate (`ellipsize_to_fit` — CSimpleFontString `0x771ec0`,
|
||
// decision 0292's residue landed): a region FontString whose wrapped text needs more lines
|
||
// than its height-pinned rect allows draws `prefix + "..."` instead — the bag title, the
|
||
// unit-frame names, the minimap zone text. Region FontStrings only, exactly the client's
|
||
// seam: the editbox windows (never truncates), message-frame ring lines size their band FROM
|
||
// the text, and auto-height FontStrings fit by construction (the measure round-trip), so the
|
||
// gate inside is geometric and needs no fixed-vs-measured flag. Computed before the shadow
|
||
// so the shadow pass draws the same display string (the client's shadow is a second draw of
|
||
// the same truncated CGxString).
|
||
let ellipsized = match host.target {
|
||
ZTarget::Region(region) if ebox.is_none() => {
|
||
crate::ui_text::ellipsize_to_fit(atlas, region, draw_text, draw_rect, spec)
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some(display) = ellipsized.as_deref() {
|
||
draw_text = display;
|
||
}
|
||
// The probe prints the DISPLAY string (post-ellipsis, post-editbox-window) — what this pass
|
||
// actually draws, which is what a truncation report needs to show.
|
||
let probe = std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1");
|
||
if probe {
|
||
// For the focused edit box, also the two numbers that must agree: where the engine puts the
|
||
// caret (`caret=`, advance-table-derived) and how wide the text this pass actually draws is
|
||
// (`ink=`). They diverge by the width of the markup when the advance table is measured over
|
||
// the raw buffer — the caret-out-in-space report (decision 1075) as a pair of numbers.
|
||
let ebox_geom = ebox.map(|ui| {
|
||
let ink = crate::ui_text::measure_text(&atlas.metrics, draw_text, None, spec).0;
|
||
format!(" caret={:.1} ink={ink:.1}", ui.caret_x * host.scale)
|
||
});
|
||
info!(
|
||
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?}{} {:?}",
|
||
draw_rect.min.x,
|
||
draw_rect.min.y,
|
||
host.rect.width(),
|
||
host.rect.height(),
|
||
style.font_height,
|
||
ebox_geom.unwrap_or_default(),
|
||
&draw_text[..draw_text.len().min(60)]
|
||
);
|
||
}
|
||
// The drop shadow (font object `<Shadow>` — MasterFont's (1,-1) black covers the whole
|
||
// GameFont* family): the same layout at an offset rect in the shadow color, pushed FIRST so
|
||
// the stable z-sort keeps it behind the glyph pass. Offset is WoW y-up (`y="-1"` = down) →
|
||
// y-down screen dy = −y. Markup color codes inside the text tint the shadow run too (v1
|
||
// corner, invisible for solid-color strings). The shadow is a single flat offset copy —
|
||
// never itself outlined (an outlined shadow would be a muddy black blob), so it suppresses
|
||
// the halo PAINT only: it keeps the font's true outline for the step law, laying out
|
||
// identically to its fill (a shadow with different steps would smear under long strings).
|
||
if let (Some(sh), Some((dx, dy))) = (style.shadow, shadow_delta) {
|
||
// WHOLE pixels (`shadow_offset_px`, computed above): the shadow is a rigid copy of the
|
||
// fill, so its displacement must be an integer in the rect's own space — see that fn for
|
||
// why a fractional one makes the offset itself wobble line to line.
|
||
let srect = Rect::new(
|
||
draw_rect.min.x + dx,
|
||
draw_rect.min.y + dy,
|
||
draw_rect.max.x + dx,
|
||
draw_rect.max.y + dy,
|
||
);
|
||
let shadow_spec = crate::ui_text::FontSpec {
|
||
paint_halo: false,
|
||
..spec
|
||
};
|
||
let mut sq = layout_text_quads(
|
||
atlas,
|
||
draw_text,
|
||
srect,
|
||
sh.color,
|
||
draw_justify,
|
||
host.z,
|
||
shadow_spec,
|
||
);
|
||
for q in &mut sq {
|
||
// Flatten rgb to the shadow color (markup tints ride the fill only), but multiply
|
||
// the layout's own alpha — it carries the shadow's base alpha AND the write-on
|
||
// gradient's per-glyph ramp (a revealing char's shadow fades with its fill).
|
||
q.color = [
|
||
sh.color[0],
|
||
sh.color[1],
|
||
sh.color[2],
|
||
q.color[3] * host.alpha,
|
||
];
|
||
// Every glyph quad inherits the owning Text quad's ScrollFrame clip — the
|
||
// FontString's own extract-time clip, not a per-glyph concept.
|
||
q.clip = text_clip;
|
||
}
|
||
out.extend(sq);
|
||
}
|
||
// `font`/`font_height` come from the region's resolved font object (`Fonts.xml`).
|
||
let mut glyphs = if let ZTarget::Frame(fh) = host.target {
|
||
// A frame-targeted Text quad is a message-frame ring line: collect its hyperlink spans
|
||
// for the engine's click hit-test (y-down → y-up flip).
|
||
let mut spans = Vec::new();
|
||
let g = layout_text_quads_links(
|
||
atlas,
|
||
text,
|
||
host.rect,
|
||
base_color,
|
||
crate::ui_text::Justify {
|
||
h: style.justify_h,
|
||
v: style.justify_v,
|
||
},
|
||
host.z,
|
||
spec,
|
||
&mut spans,
|
||
);
|
||
for sp in spans {
|
||
// px → the engine's y-up UI-unit space (÷scale after the flip).
|
||
link_spans.push((
|
||
fh,
|
||
benilla_ui::layout::Rect::new(
|
||
(host.screen_h - sp.rect.max.y) / host.scale,
|
||
sp.rect.min.x / host.scale,
|
||
(host.screen_h - sp.rect.min.y) / host.scale,
|
||
sp.rect.max.x / host.scale,
|
||
),
|
||
sp.link,
|
||
sp.markup,
|
||
));
|
||
}
|
||
g
|
||
} else {
|
||
layout_text_quads(
|
||
atlas,
|
||
draw_text,
|
||
draw_rect,
|
||
base_color,
|
||
draw_justify,
|
||
host.z,
|
||
spec,
|
||
)
|
||
};
|
||
for q in &mut glyphs {
|
||
q.color[3] *= host.alpha;
|
||
// Every glyph quad inherits the Text quad's ScrollFrame clip (decision 0112) —
|
||
// `ui_pass`'s CPU clip already applies uniformly to any `UiQuad`, glyph or not.
|
||
q.clip = text_clip;
|
||
}
|
||
// The probe's seat line: the drawn INK rows (glyph-quad union, logical px, relative to the
|
||
// rect top) — the measurable half of the vertical-seat law
|
||
// (`fontstring-vertical-placement.md`): compare `ink` against the law's `d + ascender` seat
|
||
// when hunting a vertical offset. Fill quads only (the shadow pass above would smear the
|
||
// bounds one px down-right).
|
||
if probe && !glyphs.is_empty() {
|
||
let (mut y0, mut y1) = (f32::MAX, f32::MIN);
|
||
for q in &glyphs {
|
||
y0 = y0.min(q.rect.min.y);
|
||
y1 = y1.max(q.rect.max.y);
|
||
}
|
||
info!(
|
||
"seat probe: top={:.2} ink=[{:.2}..{:.2}] (rel {:.2}..{:.2}) {:?}",
|
||
host.rect.min.y,
|
||
y0,
|
||
y1,
|
||
y0 - host.rect.min.y,
|
||
y1 - host.rect.min.y,
|
||
&draw_text[..draw_text.len().min(20)]
|
||
);
|
||
}
|
||
out.extend(glyphs);
|
||
// The focused box's caret: a 1-px WHITE bar (the client's ctor `0xffffffff` caret texture —
|
||
// never the text color) one line cell tall at the engine's advance-derived x, pushed after
|
||
// the glyphs so the stable sort draws it on top.
|
||
if let (Some(ui), Some((x0, top, cell_h))) = (ebox, ebox_geom) {
|
||
if host.caret_pinned || ui.caret_on {
|
||
#[allow(clippy::cast_precision_loss)]
|
||
let top = top + ui.caret_row as f32 * cell_h;
|
||
// caret_x is engine UI units (÷scale advances); the 1-px bar width stays device-thin
|
||
// (the client's 4-unit caret is a named residual, ui.md's caret law).
|
||
let cx = x0 + ui.caret_x * host.scale;
|
||
out.push(UiQuad {
|
||
rect: Rect::new(cx, top, cx + 1.0, top + cell_h),
|
||
z_key: host.z,
|
||
texture: None,
|
||
uv: UvRect::FULL,
|
||
color: [1.0, 1.0, 1.0, host.alpha],
|
||
additive: false,
|
||
circular: false,
|
||
desaturated: false,
|
||
clip: text_clip,
|
||
rotation: 0.0,
|
||
mask: None,
|
||
corners: None,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One axis of the drop shadow's displacement, in the rect's own px space: the font object's
|
||
/// unit offset times the seam scale, **rounded to a whole pixel** (never to zero — a shadow the
|
||
/// scale shrank below half a pixel still draws one, the reference's look at any window size).
|
||
///
|
||
/// It must be a whole pixel because the shadow is laid out as a second, offset copy of the fill,
|
||
/// and each copy independently takes the client's single vertical anchor snap (`snap_block_top`
|
||
/// — `ceil(y − 0.5)`). A snap commutes with an integer translate and *only* with an integer
|
||
/// translate: at a fractional offset `d`, `ceil(y + d − 0.5) − ceil(y − 0.5)` is `⌈d⌉` for some
|
||
/// fractional parts of `y` and `⌊d⌋` for others — so the shadow's distance from its own glyphs
|
||
/// changed with where the string happened to land. The chat window made that visible and
|
||
/// permanent: its newest line's band is pinned to the frame's bottom edge, one fixed fractional
|
||
/// position, which sat inside the wide-by-one window — so the most recent message, and only it,
|
||
/// wore a shadow a pixel further out than every line above it (director, 2026-07-26; measured at
|
||
/// 1600×900 ×0.9 UI scale, seam scale 1.055 → the fill/shadow gap alternated 1 px and 2 px).
|
||
///
|
||
/// The world's floating combat text reached the same conclusion from its own law
|
||
/// (`combat_text::law::shadow_offset_px` rounds `0.002·viewport`); this is the UI's half.
|
||
/// How far below its band a message-frame line's ink can reach, in the rect's own px space — the
|
||
/// slack its scissor's bottom edge has to give back.
|
||
///
|
||
/// Two renderer offsets put ink under the geometric band, and neither existed when the band model
|
||
/// was written: the seat nudge ([`crate::ui_text::UI_SEAT_NUDGE`]) drops every UI text block one
|
||
/// px, and the drop shadow ([`shadow_offset_px`]) draws a copy one further px down. A shadow that
|
||
/// rises (`dy < 0`) adds nothing at the bottom.
|
||
fn band_clip_slack(shadow_dy: Option<f32>) -> f32 {
|
||
crate::ui_text::UI_SEAT_NUDGE + shadow_dy.map_or(0.0, |dy| dy.max(0.0))
|
||
}
|
||
|
||
fn shadow_offset_px(scaled: f32) -> f32 {
|
||
let rounded = scaled.round();
|
||
if rounded == 0.0 && scaled != 0.0 {
|
||
scaled.signum()
|
||
} else {
|
||
rounded
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{band_clip_slack, shadow_offset_px};
|
||
|
||
/// The newest chat line's band bottom IS the frame's bottom edge, so every px the renderer
|
||
/// adds below the band is a px the scissor would otherwise eat — the descender tails and
|
||
/// bracket feet the director saw sliced off. The slack is the sum of the two offsets that put
|
||
/// ink there, and nothing else.
|
||
#[test]
|
||
fn the_band_scissor_gives_back_exactly_the_seat_and_the_shadow() {
|
||
// The shipped GameFont case: seat nudge 1 + a 1px drop shadow.
|
||
assert_eq!(band_clip_slack(Some(1.0)), 2.0);
|
||
// A font object with no shadow still owes the seat.
|
||
assert_eq!(band_clip_slack(None), 1.0);
|
||
// A shadow that rises adds nothing under the line.
|
||
assert_eq!(band_clip_slack(Some(-1.0)), 1.0);
|
||
// A bigger window scales the shadow, and the slack with it.
|
||
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
|
||
}
|
||
|
||
/// The whole-pixel law, at the three scales that matter: the 1:1 seam (offset 1 px), the
|
||
/// director's 1600×900 ×0.9 (1.055 → 1 px, the fix's own case), and a small capture window
|
||
/// whose scale would otherwise round the shadow away entirely.
|
||
#[test]
|
||
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
|
||
assert_eq!(shadow_offset_px(1.0), 1.0);
|
||
assert_eq!(shadow_offset_px(1.0546875), 1.0);
|
||
assert_eq!(shadow_offset_px(-1.0546875), -1.0);
|
||
assert_eq!(shadow_offset_px(2.109375), 2.0);
|
||
// 377-tall capture window × 0.9: 0.44 px would round to nothing.
|
||
assert_eq!(shadow_offset_px(0.4418), 1.0);
|
||
assert_eq!(shadow_offset_px(-0.4418), -1.0);
|
||
// A font object with no offset on an axis keeps none.
|
||
assert_eq!(shadow_offset_px(0.0), 0.0);
|
||
}
|
||
|
||
/// The property the fix exists for: an integer translate commutes with the client's single
|
||
/// vertical snap (`ceil(y − 0.5)`), so the fill→shadow gap is the same at every fractional
|
||
/// position a string can land on. A fractional offset does not — the sweep below is exactly
|
||
/// the wobble the chat window's newest line wore.
|
||
#[test]
|
||
fn a_whole_pixel_offset_survives_the_anchor_snap_at_every_fraction() {
|
||
let snap = |y: f32| (y - 0.5).ceil();
|
||
let d = shadow_offset_px(1.0546875);
|
||
for step in 0..1000 {
|
||
let y = 100.0 + step as f32 / 1000.0;
|
||
assert_eq!(
|
||
snap(y + d) - snap(y),
|
||
d,
|
||
"whole-px offset must translate the snapped block exactly, y={y}"
|
||
);
|
||
}
|
||
// …and the unrounded offset provably does not (both gaps occur over one unit of y).
|
||
let raw = 1.0546875f32;
|
||
let gaps: std::collections::BTreeSet<i32> = (0..1000)
|
||
.map(|step| {
|
||
let y = 100.0 + step as f32 / 1000.0;
|
||
(snap(y + raw) - snap(y)) as i32
|
||
})
|
||
.collect();
|
||
assert_eq!(gaps, [1, 2].into_iter().collect());
|
||
}
|
||
}
|