use std::borrow::Cow;
use kurbo::Rect;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Array, Dict, Name, Object, Resolve};
use crate::ap::border::BorderStyle;
use crate::ap::emit::{Content, Float, PaintOp, color_op_via};
use crate::ap::{TextFont, da, freetext, shapes, widget};
use crate::color::Color;
use crate::form::attr;
use crate::geom;
use crate::names;
use crate::vt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Kind {
Text,
Combo,
List,
Button,
}
impl Kind {
#[must_use]
pub fn of<R: Resolve>(dict: &Dict, r: &R) -> Option<Kind> {
let kind = inherited(dict, names::FT, r)
.map(|value| value.to_byte_string())
.unwrap_or_default();
match kind.as_slice() {
b"Tx" => Some(Kind::Text),
b"Ch" if flags(dict, r) & FLAG_COMBO != 0 => Some(Kind::Combo),
b"Ch" => Some(Kind::List),
b"Btn" if flags(dict, r) & FLAG_PUSH_BUTTON != 0 => Some(Kind::Button),
_ => None,
}
}
}
const FLAG_COMBO: i64 = 1 << 17;
const FLAG_PUSH_BUTTON: i64 = 1 << 16;
const FLAG_MULTILINE: i64 = 1 << 12;
const FLAG_PASSWORD: i64 = 1 << 13;
const FLAG_COMB: i64 = 1 << 24;
const DROP_BUTTON_WIDTH: f32 = 13.0;
const LIST_ROW_DEFAULT_SIZE: f32 = 12.0;
const LIST_SCROLLBAR_WIDTH: f32 = 12.0;
const SELECTION_FILL: Color = Color::Rgb(0.0, 51.0 / 255.0, 113.0 / 255.0);
pub const CARET_WIDTH: f32 = 0.4;
#[derive(Debug, Clone, PartialEq)]
pub struct Highlight {
pub caret: Option<Rect>,
pub selection: Vec<Rect>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LiveState<'a> {
pub text: &'a str,
pub selected: &'a [usize],
pub top_visible: usize,
pub scroll: (f32, f32),
}
impl Default for LiveState<'_> {
fn default() -> Self {
LiveState {
text: "",
selected: &[],
top_visible: 0,
scroll: (0.0, 0.0),
}
}
}
impl LiveState<'_> {
#[must_use]
fn shift(&self) -> (f32, f32) {
(-self.scroll.0, -self.scroll.1)
}
}
fn live_shift(live: Option<&LiveState<'_>>) -> (f32, f32) {
live.map_or((0.0, 0.0), LiveState::shift)
}
const DEFAULT_FONT_ALIAS: &[u8] = b"Helvetica_00";
fn inherited<R: Resolve>(dict: &Dict, key: &Name, r: &R) -> Option<Object> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
attr::field_attr(dict, key, r, &limits, &mut diags)
}
fn flags<R: Resolve>(dict: &Dict, r: &R) -> i64 {
inherited(dict, names::FF, r)
.and_then(|value| value.as_int())
.unwrap_or(0)
}
#[must_use]
pub fn client_rect<R: Resolve>(dict: &Dict, r: &R) -> Rect {
let width = widget::widget_border(dict, r).width;
geom::normalize(geom::deflate(widget::rotated_rect(dict, r), width, width))
}
#[must_use]
pub(crate) fn text_color<R: Resolve>(dict: &Dict, r: &R) -> Color {
inherited(dict, names::DA, r)
.map(|value| value.to_byte_string())
.and_then(|string| da::color(&string))
.unwrap_or(Color::Gray(0.0))
}
#[must_use]
pub fn alignment<R: Resolve>(dict: &Dict, r: &R) -> vt::Alignment {
if let Some(own) = dict.int(names::Q, r) {
return vt::Alignment::from_quadding(own);
}
vt::Alignment::from_quadding(
inherited(dict, names::Q, r)
.and_then(|value| value.as_int())
.unwrap_or(0),
)
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Body {
pub stream: Vec<u8>,
pub font_resources: Option<Dict>,
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub(crate) fn generate<R: Resolve>(
dict: &Dict,
catalog: &Dict,
font: &TextFont<'_>,
substitute: Option<crate::ap::Substitute<'_>>,
r: &R,
caret_and_selection: Option<&Highlight>,
live: Option<&LiveState<'_>>,
) -> Option<Body> {
let kind = Kind::of(dict, r)?;
let form = catalog.dict(names::ACRO_FORM, r);
let empty = Dict::new();
let appearance = freetext::default_appearance(dict, form.as_ref().unwrap_or(&empty), r)
.filter(|appearance| !appearance.font_name.is_empty())
.unwrap_or(freetext::Appearance {
font_name: DEFAULT_FONT_ALIAS.to_vec(),
size: if kind == Kind::Button { 12.0 } else { 0.0 },
color: Color::Transparent,
});
let client = client_rect(dict, r);
let color = text_color(dict, r);
let valued = field_dict_of(dict, form.as_ref(), r);
let valued = valued.as_ref().unwrap_or(dict);
let input = BodyInput {
widget: dict,
valued,
client,
appearance: &appearance,
color,
font,
substitute,
caret_and_selection,
live,
};
let mut out = Content::new();
match kind {
Kind::Text => text_field(&mut out, &input, r),
Kind::Combo => combo_box(&mut out, &input, r),
Kind::List => list_box(&mut out, &input, r),
Kind::Button => push_button(&mut out, &input, r),
}
if out.is_empty() {
return None;
}
Some(Body {
stream: out.into_bytes(),
font_resources: font_resources(&appearance, substitute, form.as_ref(), r),
})
}
fn field_dict_of<R: Resolve>(dict: &Dict, form: Option<&Dict>, r: &R) -> Option<Dict> {
if dict.contains_key(names::PARENT) {
return None;
}
let name = dict.byte_string(names::T, r)?;
let fields = form?.array(names::FIELDS, r)?;
let first = (0..fields.len())
.filter_map(|index| fields.dict_at(index, r))
.find(|entry| entry.byte_string(names::T, r).as_deref() == Some(name.as_slice()))?;
(first != *dict).then_some(first)
}
fn font_resources<R: Resolve>(
appearance: &freetext::Appearance,
substitute: Option<crate::ap::Substitute<'_>>,
form: Option<&Dict>,
r: &R,
) -> Option<Dict> {
if appearance.font_name.is_empty() {
return None;
}
let name = Name::new(appearance.font_name.clone());
let entry = form
.and_then(|form| form.dict(names::DR, r))
.and_then(|resources| resources.dict(names::FONT, r))
.and_then(|fonts| fonts.dict(&name, r))
.unwrap_or_else(freetext::fallback_font);
let mut resources = Dict::from_pairs([(name, Object::Dict(entry))]);
if let Some(sub) = substitute {
resources.push(sub.alias.clone(), Object::Dict(sub.dict.clone()));
}
Some(resources)
}
fn vertical_offset(centred: bool, plate: Rect, content: Rect) -> (f32, f32) {
if !centred {
return (0.0, 0.0);
}
(0.0, (geom::height(content) - geom::height(plate)) * 0.5)
}
#[allow(clippy::too_many_arguments)]
fn set_text(
text: &str,
config: &vt::Config,
font: &TextFont<'_>,
substitute: Option<crate::ap::Substitute<'_>>,
offset_centred: bool,
grouping: vt::edit_ap::Grouping,
alias: &[u8],
shift: (f32, f32),
) -> (String, Rect) {
let layout = vt::layout(text, config, &font.metrics);
let content = layout.content_rect_pdf(config.plate);
let padding = vertical_offset(offset_centred, config.plate, content);
let offset = (padding.0 + shift.0, padding.1 + shift.1);
let written = vt::edit_ap::generate(&layout, config, &font.metrics, offset, grouping, |code| {
face_for(font, substitute, alias, code)
});
(written, content)
}
fn face_for(
font: &TextFont<'_>,
substitute: Option<crate::ap::Substitute<'_>>,
alias: &[u8],
code: u32,
) -> vt::edit_ap::Face {
let da_charset = crate::ap::font_map::font_charset(font.font);
match substitute {
Some(sub) if !crate::ap::font_map::da_font_writes(font.font, da_charset, code) => {
vt::edit_ap::Face {
index: 1,
alias: sub.alias.as_bytes().to_vec(),
bytes: crate::ap::font_map::substitute_encode(sub.font, code),
}
}
_ => vt::edit_ap::Face::single(alias, font.encode(code)),
}
}
fn wrap_text(
out: &mut Content,
plate: Rect,
content: Rect,
color: Color,
written: &str,
caret_and_selection: Option<&Highlight>,
) {
let overlay = caret_and_selection.filter(|h| h.caret.is_some() || !h.selection.is_empty());
if written.is_empty() && overlay.is_none() {
return;
}
out.raw("/Tx BMC\nq\n");
if geom::width(content) > geom::width(plate) || geom::height(content) > geom::height(plate) {
out.rect(plate, Float::Shortest);
out.raw("re\nW\nn\n");
}
if let Some(highlight) = overlay {
for band in &highlight.selection {
out.raw("q\n");
out.raw(&color_op_via(SELECTION_FILL, PaintOp::Fill, Float::G6));
out.rect(*band, Float::Shortest);
out.raw("re\nf\nQ\n");
}
}
if !written.is_empty() {
out.raw("BT\n");
out.raw(&color_op_via(color, PaintOp::Fill, Float::G6));
out.raw(written);
out.raw("ET\n");
let bands = overlay.map(|h| h.selection.as_slice()).unwrap_or_default();
if !bands.is_empty() {
out.raw("q\n");
for band in bands {
out.rect(*band, Float::Shortest);
out.raw("re\n");
}
out.raw("W\nn\n");
out.raw("BT\n");
out.raw(&color_op_via(Color::Gray(1.0), PaintOp::Fill, Float::G6));
out.raw(written);
out.raw("ET\n");
out.raw("Q\n");
}
}
if let Some(caret) = overlay.and_then(|h| {
if h.selection.is_empty() {
h.caret
} else {
None
}
}) {
out.raw("q\n");
out.raw(&color_op_via(Color::Gray(0.0), PaintOp::Fill, Float::G6));
out.rect(caret, Float::Shortest);
out.raw("re\nf\nQ\n");
}
out.raw("Q\nEMC\n");
}
struct BodyInput<'a> {
widget: &'a Dict,
valued: &'a Dict,
client: Rect,
appearance: &'a freetext::Appearance,
color: Color,
font: &'a TextFont<'a>,
substitute: Option<crate::ap::Substitute<'a>>,
caret_and_selection: Option<&'a Highlight>,
live: Option<&'a LiveState<'a>>,
}
impl BodyInput<'_> {
fn text<R: Resolve>(&self, r: &R) -> Cow<'_, str> {
match self.live {
Some(live) => Cow::Borrowed(live.text),
None => Cow::Owned(field_value(self.valued, r)),
}
}
}
fn text_field<R: Resolve>(out: &mut Content, input: &BodyInput<'_>, r: &R) {
let (dict, client) = (input.widget, input.client);
let (appearance, color, font) = (input.appearance, input.color, input.font);
let flags = flags(dict, r);
let multi_line = flags & FLAG_MULTILINE != 0;
let comb = flags & FLAG_COMB != 0;
let max_len = inherited(dict, names::MAX_LEN, r)
.and_then(|value| value.as_int())
.unwrap_or(0);
let value = input.text(r);
let mut config = vt::Config {
plate: client,
alignment: alignment(dict, r),
font_size: appearance.size,
multi_line,
auto_return: multi_line,
sub_word: (flags & FLAG_PASSWORD != 0).then_some('*'),
..vt::Config::default()
};
if max_len > 0 {
let cells = usize::try_from(max_len).unwrap_or(0);
if comb {
config.char_array = cells;
} else {
config.limit_char = cells;
}
}
comb_separators(out, dict, client, if comb { max_len } else { 0 }, r);
let (written, content) = set_text(
&value,
&config,
font,
input.substitute,
!multi_line,
if comb {
vt::edit_ap::Grouping::PerCharacter
} else {
vt::edit_ap::Grouping::Continuous
},
&appearance.font_name,
live_shift(input.live),
);
wrap_text(
out,
client,
content,
color,
&written,
input.caret_and_selection,
);
}
fn push_button<R: Resolve>(out: &mut Content, input: &BodyInput<'_>, r: &R) {
let (dict, client) = (input.widget, input.client);
let (appearance, color, font) = (input.appearance, input.color, input.font);
let Some(mk) = dict.dict(names::MK, r) else {
return;
};
if !mk.contains_key(names::CA) {
return;
}
let caption = mk.text(names::CA, r).unwrap_or_default();
let config = vt::Config {
plate: client,
alignment: vt::Alignment::Center,
font_size: appearance.size,
..vt::Config::default()
};
let (written, content) = set_text(
&caption,
&config,
font,
input.substitute,
true,
vt::edit_ap::Grouping::Continuous,
&appearance.font_name,
(0.0, 0.0),
);
if written.is_empty() {
return;
}
out.raw("q\n");
out.rect(client, Float::Shortest);
out.raw("re\nW n\n");
out.raw("BT\n");
out.raw(&color_op_via(color, PaintOp::Fill, Float::G6));
out.raw(&written);
out.raw("ET\nQ\n");
let _ = content;
}
fn comb_separators<R: Resolve>(out: &mut Content, dict: &Dict, client: Rect, cells: i64, r: &R) {
if cells <= 1 {
return;
}
let info = widget::widget_border(dict, r);
let dashed = match info.style {
BorderStyle::Solid => false,
BorderStyle::Dash => true,
BorderStyle::Beveled | BorderStyle::Inset | BorderStyle::Underline => return,
};
let stroke = color_op_via(border_color(dict, r), PaintOp::Stroke, Float::G6);
if stroke.is_empty() {
return;
}
out.raw("q\n");
out.num(info.width, Float::G6);
out.raw("w\n");
out.raw(&stroke);
if dashed {
out.raw("[3 3] 0 d\n");
} else {
out.raw(" 2 J 0 j\n");
}
let total = u16::try_from(cells).map_or(f32::from(u16::MAX), f32::from);
let width = geom::width(client);
for cell in 1..cells {
let left = geom::left(client)
+ (width / total) * u16::try_from(cell).map_or(f32::from(u16::MAX), f32::from);
out.point(left, geom::bottom(client), Float::Shortest);
out.raw("m\n");
out.point(left, geom::top(client), Float::Shortest);
out.raw("l\n");
out.raw("S\n");
}
out.raw("Q\n");
}
fn combo_box<R: Resolve>(out: &mut Content, input: &BodyInput<'_>, r: &R) {
let (valued, client) = (input.valued, input.client);
let (appearance, color, font) = (input.appearance, input.color, input.font);
let button = geom::normalize(geom::rect(
geom::right(client) - DROP_BUTTON_WIDTH,
geom::bottom(client),
geom::right(client),
geom::top(client),
));
let plate = geom::normalize(geom::rect(
geom::left(client),
geom::bottom(client),
geom::left(button),
geom::top(client),
));
let options = options(valued, r);
let text: Cow<'_, str> = match input.live {
Some(live) => Cow::Borrowed(live.text),
None => match selected_indices(valued, &options, r).first().copied() {
Some(index) => options
.get(index)
.map_or(Cow::Borrowed(""), |option| Cow::Owned(option.label.clone())),
None => Cow::Owned(field_value(valued, r)),
},
};
let config = vt::Config {
plate,
font_size: appearance.size,
..vt::Config::default()
};
let (written, content) = set_text(
&text,
&config,
font,
input.substitute,
true,
vt::edit_ap::Grouping::Continuous,
&appearance.font_name,
live_shift(input.live),
);
wrap_text(
out,
plate,
content,
color,
&written,
input.caret_and_selection,
);
out.raw(&shapes::drop_button(button));
}
fn list_box<R: Resolve>(out: &mut Content, input: &BodyInput<'_>, r: &R) {
let (dict, valued) = (input.widget, input.valued);
let client = if input.live.is_some() {
geom::rect(
geom::left(input.client),
geom::bottom(input.client),
geom::right(input.client) - LIST_SCROLLBAR_WIDTH,
geom::top(input.client),
)
} else {
input.client
};
let (appearance, color, font) = (input.appearance, input.color, input.font);
let options = options(valued, r);
let selected: Cow<'_, [usize]> = input.live.map_or_else(
|| Cow::Owned(selected_indices(valued, &options, r)),
|live| Cow::Borrowed(live.selected),
);
let top = input.live.map_or_else(
|| {
usize::try_from(
inherited(dict, names::TI, r)
.and_then(|value| value.as_int())
.unwrap_or(0),
)
.unwrap_or(0)
},
|live| live.top_visible,
);
let (shift_x, shift_y) = live_shift(input.live);
let plate = geom::rect(geom::left(client), 0.0, geom::right(client), 0.0);
let config = vt::Config {
plate,
font_size: if geom::is_float_zero(appearance.size) {
LIST_ROW_DEFAULT_SIZE
} else {
appearance.size
},
..vt::Config::default()
};
let mut rows = Content::new();
let mut y = geom::top(client);
for (index, option) in options.iter().enumerate().skip(top) {
let layout = vt::layout(&option.label, &config, &font.metrics);
let height = geom::height(layout.content_rect_pdf(plate));
let written = vt::edit_ap::generate(
&layout,
&config,
&font.metrics,
(shift_x, y + shift_y),
vt::edit_ap::Grouping::Continuous,
|code| face_for(font, input.substitute, &appearance.font_name, code),
);
if selected.contains(&index) {
rows.raw("q\n");
rows.raw(&color_op_via(SELECTION_FILL, PaintOp::Fill, Float::G6));
rows.rect(
geom::rect(
geom::left(client) + shift_x,
y + shift_y - height,
geom::right(client) + shift_x,
y + shift_y,
),
Float::Shortest,
);
rows.raw("re\nf\nQ\n");
rows.raw("BT\n");
rows.raw(&color_op_via(Color::Gray(1.0), PaintOp::Fill, Float::G6));
} else {
rows.raw("BT\n");
rows.raw(&color_op_via(color, PaintOp::Fill, Float::G6));
}
rows.raw(&written);
rows.raw("ET\n");
y -= height;
}
if rows.is_empty() {
return;
}
out.raw("/Tx BMC\nq\n");
out.rect(client, Float::Shortest);
out.raw("re\nW\nn\n");
out.raw(rows.as_str());
out.raw("Q\nEMC\n");
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
pub value: String,
pub label: String,
}
#[must_use]
pub fn options<R: Resolve>(dict: &Dict, r: &R) -> Vec<Choice> {
let Some(array) = inherited(dict, names::OPT, r).and_then(|value| value.as_array().cloned())
else {
return Vec::new();
};
(0..array.len())
.map(|index| {
let entry = array.get(index, r);
if let Some(pair) = entry.as_ref().and_then(|object| object.as_array()) {
return Choice {
value: text_at(pair, 0, r),
label: text_at(pair, 1, r),
};
}
let text = entry.as_deref().map(Object::to_text).unwrap_or_default();
Choice {
value: text.clone(),
label: text,
}
})
.collect()
}
fn text_at<R: Resolve>(array: &Array, index: usize, r: &R) -> String {
array
.get(index, r)
.as_deref()
.map(Object::to_text)
.unwrap_or_default()
}
#[must_use]
pub fn field_value<R: Resolve>(dict: &Dict, r: &R) -> String {
let Some(value) = inherited(dict, names::V, r) else {
return String::new();
};
match value.as_array() {
Some(array) => array
.get(0, r)
.as_deref()
.map(Object::to_text)
.unwrap_or_default(),
None => value.to_text(),
}
}
#[must_use]
pub(crate) fn selected_indices<R: Resolve>(dict: &Dict, options: &[Choice], r: &R) -> Vec<usize> {
let Some(value) = inherited(dict, names::V, r).or_else(|| inherited(dict, names::I, r)) else {
return Vec::new();
};
if let Some(index) = value.as_int() {
return usize::try_from(index).into_iter().collect();
}
let wanted: Vec<String> = match value.as_array() {
Some(array) => (0..array.len())
.map(|index| {
array
.get(index, r)
.as_deref()
.map(Object::to_text)
.unwrap_or_default()
})
.collect(),
None => vec![value.to_text()],
};
wanted
.into_iter()
.filter_map(|text| options.iter().position(|option| option.value == text))
.collect()
}
fn border_color<R: Resolve>(dict: &Dict, r: &R) -> Color {
dict.dict(names::MK, r)
.and_then(|mk| mk.array(names::BC, r))
.map_or(Color::Transparent, |array| Color::from_array(&array))
}
#[cfg(test)]
mod tests {
use super::{
CARET_WIDTH, Choice, Highlight, Kind, LiveState, field_dict_of, field_value, options,
selected_indices,
};
use crate::ap::{TextFont, freetext};
use crate::geom;
use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn shared(field_name: &str, value: &str) -> Dict {
dict(&[
("Subtype", Object::Name(Name::from("Widget"))),
("FT", Object::Name(Name::from("Tx"))),
("T", text(field_name)),
("V", text(value)),
])
}
#[test]
fn a_second_fields_entry_sharing_a_name_takes_the_firsts_value() {
let (first, second) = (shared("Same", "Hello, world"), shared("Same", ""));
let form = dict(&[(
"Fields",
Object::Array(Array::of([
Object::Dict(first.clone()),
Object::Dict(second.clone()),
])),
)]);
assert_eq!(field_dict_of(&first, Some(&form), &NoResolve), None);
assert_eq!(
field_dict_of(&second, Some(&form), &NoResolve).as_ref(),
Some(&first)
);
assert_eq!(field_value(&second, &NoResolve), "");
assert_eq!(field_value(&first, &NoResolve), "Hello, world");
}
#[test]
fn a_widget_the_form_does_not_share_a_name_with_is_its_own_field() {
let alone = shared("Alone", "mine");
let form = dict(&[(
"Fields",
Object::Array(Array::of([Object::Dict(shared("Other", "theirs"))])),
)]);
assert_eq!(field_dict_of(&alone, Some(&form), &NoResolve), None);
assert_eq!(field_dict_of(&alone, None, &NoResolve), None);
}
#[test]
fn a_widget_under_a_parent_inherits_rather_than_sharing() {
let mut kid = shared("Same", "");
kid.push(
Name::from("Parent"),
Object::Dict(dict(&[("V", text("from the parent"))])),
);
let form = dict(&[(
"Fields",
Object::Array(Array::of([Object::Dict(shared("Same", "elsewhere"))])),
)]);
assert_eq!(field_dict_of(&kid, Some(&form), &NoResolve), None);
}
fn strings(values: &[&str]) -> Object {
Object::Array(Array::of(
values
.iter()
.map(|s| Object::Str(PdfString::literal(s.as_bytes()))),
))
}
fn text(value: &str) -> Object {
Object::Str(PdfString::literal(value.as_bytes()))
}
fn utf16(value: &str) -> Object {
let mut bytes = vec![0xFE, 0xFF];
for unit in value.encode_utf16() {
bytes.extend_from_slice(&unit.to_be_bytes());
}
Object::Str(PdfString::literal(bytes))
}
fn numbers(values: &[f32]) -> Object {
Object::Array(Array::of(values.iter().copied().map(Object::from)))
}
fn catalog() -> Dict {
dict(&[(
"AcroForm",
Object::Dict(dict(&[(
"DR",
Object::Dict(dict(&[(
"Font",
Object::Dict(dict(&[("Helv", Object::Dict(freetext::fallback_font()))])),
)])),
)])),
)])
}
fn widget_of(kind: &str, extra: &[(&str, Object)]) -> Dict {
let mut pairs = vec![
("Subtype", Object::Name(Name::from("Widget"))),
("FT", Object::Name(Name::from(kind))),
("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
(
"DA",
Object::Str(PdfString::literal(b"0 0 0 rg /Helv 12 Tf")),
),
];
pairs.extend_from_slice(extra);
dict(&pairs)
}
fn body(widget: &Dict) -> Option<String> {
body_with(widget, None)
}
fn body_with(widget: &Dict, caret_and_selection: Option<&Highlight>) -> Option<String> {
body_live(widget, caret_and_selection, None)
}
fn live_body(widget: &Dict, live: &LiveState<'_>) -> Option<String> {
body_live(widget, None, Some(live))
}
fn body_live(
widget: &Dict,
caret_and_selection: Option<&Highlight>,
live: Option<&LiveState<'_>>,
) -> Option<String> {
let cache = pdfrum_font::FontCache::new();
let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
let width = |code: u32| TextFont::char_width(&face, code);
let font = TextFont {
metrics: TextFont::metrics_of(&face, &width),
font: &face,
};
super::generate(
widget,
&catalog(),
&font,
None,
&NoResolve,
caret_and_selection,
live,
)
.map(|body| String::from_utf8_lossy(&body.stream).into_owned())
}
fn widget_fixtures() -> Vec<(&'static str, Dict)> {
vec![
("tx_hello", widget_of("Tx", &[("V", text("Hello"))])),
(
"tx_comb",
widget_of(
"Tx",
&[
("V", text("ab")),
("Ff", Object::Int(1 << 24)),
("MaxLen", Object::Int(3)),
],
),
),
(
"ch_combo",
widget_of(
"Ch",
&[
("Ff", Object::Int(1 << 17)),
(
"Opt",
Object::Array(Array::of([
strings(&["a", "Apple"]),
strings(&["b", "Banana"]),
])),
),
("V", text("b")),
],
),
),
(
"ch_list_v",
widget_of(
"Ch",
&[("Opt", strings(&["Dog", "Cat"])), ("V", text("Cat"))],
),
),
(
"ch_list_ti",
widget_of(
"Ch",
&[("Opt", strings(&["a", "b", "c"])), ("TI", Object::Int(2))],
),
),
(
"ch_list_i",
widget_of(
"Ch",
&[
("Opt", strings(&["a", "b", "c"])),
(
"I",
Object::Array(Array::of([Object::Int(0), Object::Int(2)])),
),
],
),
),
("tx_empty", widget_of("Tx", &[])),
(
"btn",
widget_of(
"Btn",
&[("MK", Object::Dict(dict(&[("CA", text("Push"))])))],
),
),
]
}
#[test]
fn a_choice_fields_kind_follows_its_combo_flag() {
let list = dict(&[("FT", Object::Name(Name::from("Ch")))]);
assert_eq!(Kind::of(&list, &NoResolve), Some(Kind::List));
let combo = dict(&[
("FT", Object::Name(Name::from("Ch"))),
("Ff", Object::Int(1 << 17)),
]);
assert_eq!(Kind::of(&combo, &NoResolve), Some(Kind::Combo));
let text = dict(&[("FT", Object::Name(Name::from("Tx")))]);
assert_eq!(Kind::of(&text, &NoResolve), Some(Kind::Text));
let button = dict(&[("FT", Object::Name(Name::from("Btn")))]);
assert_eq!(Kind::of(&button, &NoResolve), None);
}
#[test]
fn an_option_pair_names_its_value_and_its_label_apart() {
let field = dict(&[(
"Opt",
Object::Array(Array::of([
strings(&["foo", "Foo"]),
text("bar"),
Object::Array(Array::of([text("solo")])),
])),
)]);
assert_eq!(
options(&field, &NoResolve),
vec![
Choice {
value: "foo".to_owned(),
label: "Foo".to_owned()
},
Choice {
value: "bar".to_owned(),
label: "bar".to_owned()
},
Choice {
value: "solo".to_owned(),
label: String::new()
},
]
);
}
#[test]
fn a_selection_by_index_alone_selects_nothing() {
let by_index = dict(&[
("Opt", strings(&["Albania", "Belgium", "Croatia"])),
(
"I",
Object::Array(Array::of([Object::Int(1), Object::Int(2)])),
),
]);
let choices = options(&by_index, &NoResolve);
assert!(selected_indices(&by_index, &choices, &NoResolve).is_empty());
}
#[test]
fn a_value_selects_every_option_it_names_and_a_present_value_hides_the_indices() {
let field = dict(&[
("Opt", strings(&["Alpha", "Beta", "Gamma", "Delta"])),
("V", strings(&["Delta", "Beta"])),
("I", Object::Array(Array::of([Object::Int(0)]))),
]);
let choices = options(&field, &NoResolve);
assert_eq!(selected_indices(&field, &choices, &NoResolve), vec![3, 1]);
}
#[test]
fn a_multi_valued_field_shows_only_its_first_value() {
let field = dict(&[("V", strings(&["one", "two"]))]);
assert_eq!(field_value(&field, &NoResolve), "one");
let single = dict(&[("V", text("only"))]);
assert_eq!(field_value(&single, &NoResolve), "only");
assert_eq!(field_value(&Dict::new(), &NoResolve), "");
}
#[test]
fn a_value_naming_no_option_selects_nothing_but_still_reads_as_text() {
let field = dict(&[("Opt", strings(&["a", "b"])), ("V", text("z"))]);
let choices = options(&field, &NoResolve);
assert!(selected_indices(&field, &choices, &NoResolve).is_empty());
assert_eq!(field_value(&field, &NoResolve), "z");
}
#[test]
fn a_field_with_nothing_to_show_produces_no_body_at_all() {
assert_eq!(body(&widget_of("Tx", &[])), None);
assert_eq!(body(&widget_of("Ch", &[])), None);
assert_eq!(body(&widget_of("Btn", &[("V", text("x"))])), None);
}
#[test]
fn a_text_fields_value_is_set_between_the_markers() {
let got = body(&widget_of("Tx", &[("V", text("Hi"))])).expect("a body");
assert!(got.starts_with("/Tx BMC\nq\nBT\n"), "{got}");
assert!(got.contains("0 0 0 rg\n"), "{got}");
assert!(got.contains("(Hi) Tj\n"), "{got}");
assert!(got.ends_with("ET\nQ\nEMC\n"), "{got}");
assert!(!got.contains("W\nn\n"), "{got}");
}
#[test]
fn a_password_field_sets_bullets_rather_than_its_value() {
let got = body(&widget_of(
"Tx",
&[("V", text("secret")), ("Ff", Object::Int(1 << 13))],
))
.expect("a body");
assert!(got.contains("(******) Tj\n"), "{got}");
assert!(!got.contains("secret"), "{got}");
}
#[test]
fn a_comb_field_places_every_character_on_its_own() {
let got = body(&widget_of(
"Tx",
&[
("V", text("abc")),
("Ff", Object::Int(1 << 24)),
("MaxLen", Object::Int(4)),
(
"MK",
Object::Dict(dict(&[("BC", numbers(&[0.0, 0.0, 0.0]))])),
),
],
))
.expect("a body");
assert_eq!(got.matches(" Tj\n").count(), 3);
assert_eq!(got.matches("S\n").count(), 3);
assert!(
got.find("S\n") < got.find("/Tx BMC"),
"separators must come first: {got}"
);
}
#[test]
fn a_comb_field_with_no_border_colour_draws_no_separators() {
let got = body(&widget_of(
"Tx",
&[
("V", text("ab")),
("Ff", Object::Int(1 << 24)),
("MaxLen", Object::Int(3)),
],
))
.expect("a body");
assert!(!got.contains("S\n"), "{got}");
}
#[test]
fn a_combo_box_shows_the_selected_options_label_and_then_its_button() {
let got = body(&widget_of(
"Ch",
&[
("Ff", Object::Int(1 << 17)),
(
"Opt",
Object::Array(Array::of([
strings(&["a", "Apple"]),
strings(&["b", "Banana"]),
])),
),
("V", text("b")),
],
))
.expect("a body");
assert!(got.contains("(Banana) Tj\n"), "{got}");
assert!(!got.contains("(b) Tj"), "{got}");
assert!(got.contains("0.862745 g\n"), "{got}");
assert!(got.find("0.862745 g\n") > got.find("EMC"), "{got}");
}
#[test]
fn a_combo_box_whose_value_names_no_option_shows_the_value_itself() {
let got = body(&widget_of(
"Ch",
&[
("Ff", Object::Int(1 << 17)),
("Opt", strings(&["Apple"])),
("V", text("Pear")),
],
))
.expect("a body");
assert!(got.contains("(Pear) Tj\n"), "{got}");
}
#[test]
fn a_list_box_stacks_its_rows_and_paints_only_the_selected_one() {
let got = body(&widget_of(
"Ch",
&[("Opt", strings(&["Dog", "Cat"])), ("V", text("Cat"))],
))
.expect("a body");
assert!(got.contains("re\nW\nn\n"), "{got}");
assert_eq!(got.matches("BT\n").count(), 2);
assert_eq!(got.matches("0 0.2 0.443137 rg\n").count(), 1);
assert!(got.contains("1 g\n"), "{got}");
assert!(
got.contains("(Dog) Tj\n") && got.contains("(Cat) Tj\n"),
"{got}"
);
}
#[test]
fn only_a_live_list_box_keeps_the_scroll_bars_width_clear() {
let list = widget_of(
"Ch",
&[("Opt", strings(&["Dog", "Cat"])), ("V", text("Cat"))],
);
let stored = body(&list).expect("a body");
assert!(stored.contains("1 1 98 28 re\n"), "{stored}");
assert!(stored.contains(" 98 11.244 re\n"), "{stored}");
let live = live_body(
&list,
&LiveState {
selected: &[1],
..LiveState::default()
},
)
.expect("a live body");
assert!(live.contains("1 1 86 28 re\n"), "{live}");
assert!(live.contains(" 86 11.244 re\n"), "{live}");
}
#[test]
fn a_list_box_starts_at_its_top_visible_index() {
let got = body(&widget_of(
"Ch",
&[("Opt", strings(&["a", "b", "c"])), ("TI", Object::Int(2))],
))
.expect("a body");
assert_eq!(got.matches("BT\n").count(), 1);
assert!(got.contains("(c) Tj\n"), "{got}");
}
#[test]
fn a_list_box_selected_only_by_index_paints_no_row() {
let got = body(&widget_of(
"Ch",
&[
("Opt", strings(&["a", "b", "c"])),
(
"I",
Object::Array(Array::of([Object::Int(0), Object::Int(2)])),
),
],
))
.expect("a body");
assert!(!got.contains("0 0.2 0.443137 rg\n"), "{got}");
assert_eq!(got.matches("BT\n").count(), 3);
}
#[test]
fn none_is_byte_identical_on_every_existing_widget_fixture() {
let want = include_str!("../../tests/data/unfocused_field_bodies.txt");
let mut got = String::new();
for (name, widget) in widget_fixtures() {
got.push_str("=== ");
got.push_str(name);
got.push_str(" ===\n");
match body_with(&widget, None) {
Some(stream) => got.push_str(&stream),
None => got.push_str("<none>\n"),
}
}
assert_eq!(got, want, "the unfocused path changed shape");
}
#[test]
fn a_caret_is_a_filled_rectangle_four_tenths_wide() {
let caret = geom::rect(110.0, 104.0, 110.0 + CARET_WIDTH, 120.0);
let highlight = Highlight {
caret: Some(caret),
selection: Vec::new(),
};
let got =
body_with(&widget_of("Tx", &[("V", text("Hello"))]), Some(&highlight)).expect("a body");
let none = body(&widget_of("Tx", &[("V", text("Hello"))])).expect("a body");
assert_ne!(got, none, "a caret must change the stream");
assert!(got.contains("0 g\n"), "{got}");
assert!(got.contains("re\nf\n"), "{got}");
let et = got.find("ET\n").expect("text");
let re = got.find("re\nf\n").expect("caret fill");
assert!(re > et, "caret sits on top of the text: {got}");
assert!((CARET_WIDTH - 0.4).abs() < f32::EPSILON);
}
#[test]
fn a_selection_band_is_filled_behind_white_text() {
let band = geom::rect(100.0, 100.0, 140.0, 130.0);
let highlight = Highlight {
caret: Some(geom::rect(140.0, 104.0, 140.4, 120.0)),
selection: vec![band],
};
let got =
body_with(&widget_of("Tx", &[("V", text("Hello"))]), Some(&highlight)).expect("a body");
assert!(got.contains("0 0.2 0.443137 rg\n"), "{got}");
assert!(got.contains("1 g\n"), "{got}");
let sel = got.find("0 0.2 0.443137 rg\n").expect("band");
let bt = got.find("BT\n").expect("text");
assert!(sel < bt, "selection sits behind the text: {got}");
assert!(
!got.contains("0 g\n"),
"a selection suppresses the caret: {got}"
);
}
#[test]
fn a_partial_selection_leaves_the_unselected_run_in_the_fields_colour() {
let band = geom::rect(120.0, 104.0, 140.0, 120.0);
let highlight = Highlight {
caret: None,
selection: vec![band],
};
let got = body_with(
&widget_of("Tx", &[("V", text("ABCDEFGH"))]),
Some(&highlight),
)
.expect("a body");
let dark = got.find("0 0 0 rg\n").expect("the field colour");
let white = got.find("1 g\n").expect("the selected colour");
assert!(dark < white, "{got}");
assert_eq!(got.matches("W\nn\n").count(), 1, "{got}");
assert!(got.contains("120 104 20 16 re\n"), "{got}");
assert_eq!(got.matches("(ABCDEFGH) Tj\n").count(), 2, "{got}");
}
#[test]
fn every_band_contributes_to_the_one_clip_the_white_pass_runs_under() {
let highlight = Highlight {
caret: None,
selection: vec![
geom::rect(110.0, 104.0, 120.0, 120.0),
geom::rect(140.0, 104.0, 150.0, 120.0),
],
};
let got = body_with(
&widget_of("Tx", &[("V", text("ABCDEFGH"))]),
Some(&highlight),
)
.expect("a body");
assert_eq!(got.matches("W\nn\n").count(), 1, "{got}");
assert!(got.contains("110 104 10 16 re\n"), "{got}");
assert!(got.contains("140 104 10 16 re\n"), "{got}");
assert_eq!(got.matches("(ABCDEFGH) Tj\n").count(), 2, "{got}");
}
#[test]
fn no_selection_writes_one_text_pass_and_no_clip() {
let got = body(&widget_of("Tx", &[("V", text("ABCDEFGH"))])).expect("a body");
assert_eq!(got.matches("(ABCDEFGH) Tj\n").count(), 1, "{got}");
assert!(!got.contains("1 g\n"), "{got}");
assert!(!got.contains("W\nn\n"), "{got}");
}
#[test]
fn an_empty_field_with_a_caret_still_emits_a_body() {
let highlight = Highlight {
caret: Some(geom::rect(101.0, 101.0, 101.4, 129.0)),
selection: Vec::new(),
};
let got = body_with(&widget_of("Tx", &[]), Some(&highlight)).expect("caret-only body");
assert!(got.contains("/Tx BMC\n"), "{got}");
assert!(got.contains("re\nf\n"), "{got}");
assert!(!got.contains("BT\n"), "{got}");
}
#[test]
fn a_value_the_da_font_cannot_write_switches_to_a_second_face() {
let cache = pdfrum_font::FontCache::new();
let options = pdfrum_font::SubstitutionOptions::default();
let mut ctx = pdfrum_page::BuildContext::with_substitution(options);
let fonts = crate::ap::FormFonts::load(&catalog(), &NoResolve, &mut ctx);
let substitute = fonts
.substitute(pdfrum_font::Charset::Hebrew)
.expect("a Hebrew substitute");
let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
let charset = crate::ap::font_map::font_charset(&face);
let width = |code: u32| {
if crate::ap::font_map::da_font_writes(&face, charset, code) {
TextFont::char_width(&face, code)
} else {
crate::ap::font_map::substitute_width(substitute.font, code)
}
};
let font = TextFont {
metrics: TextFont::metrics_of(&face, &width),
font: &face,
};
let widget = widget_of("Tx", &[("V", utf16("ab\u{5D0}\u{5D1}"))]);
let body = super::generate(
&widget,
&catalog(),
&font,
Some(substitute),
&NoResolve,
None,
None,
)
.expect("a body");
let stream = body.stream.clone();
let has = |needle: &[u8]| stream.windows(needle.len()).any(|w| w == needle);
assert!(has(b"/Helv 12 Tf\n"), "{stream:02X?}");
let mut tf = b"/".to_vec();
tf.extend_from_slice(substitute.alias.as_bytes());
tf.extend_from_slice(b" 12 Tf\n");
assert!(has(&tf), "{stream:02X?}");
assert!(has(b"\\340"), "aleph as 0xE0: {stream:02X?}");
assert!(has(b"\\341"), "bet as 0xE1: {stream:02X?}");
assert!(!has(b"\\320"), "no low-byte aleph: {stream:02X?}");
assert!(!has(b"\\321"), "no low-byte bet: {stream:02X?}");
let resources = body.font_resources.expect("font resources");
assert!(
resources.contains_key(&pdfrum_object::Name::from("Helv")),
"{resources:?}"
);
assert!(resources.contains_key(substitute.alias), "{resources:?}");
}
#[test]
fn a_latin_value_writes_no_font_switch_even_with_a_substitute_in_hand() {
let cache = pdfrum_font::FontCache::new();
let options = pdfrum_font::SubstitutionOptions::default();
let mut ctx = pdfrum_page::BuildContext::with_substitution(options);
let fonts = crate::ap::FormFonts::load(&catalog(), &NoResolve, &mut ctx);
let substitute = fonts
.substitute(pdfrum_font::Charset::Hebrew)
.expect("a Hebrew substitute");
let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
let width = |code: u32| TextFont::char_width(&face, code);
let font = TextFont {
metrics: TextFont::metrics_of(&face, &width),
font: &face,
};
let widget = widget_of("Tx", &[("V", text("Hello"))]);
let offered = super::generate(
&widget,
&catalog(),
&font,
Some(substitute),
&NoResolve,
None,
None,
)
.expect("a body");
let plain = super::generate(&widget, &catalog(), &font, None, &NoResolve, None, None)
.expect("a body");
assert_eq!(
offered.stream, plain.stream,
"a substitute nothing reaches writes the same stream"
);
}
#[test]
fn a_live_text_field_draws_what_the_session_holds_rather_than_its_value() {
let empty = widget_of("Tx", &[]);
assert_eq!(body(&empty), None, "an empty field draws nothing");
let typed = live_body(
&empty,
&LiveState {
text: "Hello",
..LiveState::default()
},
)
.expect("a live body");
assert!(typed.contains("(Hello) Tj\n"), "{typed}");
let stale = widget_of("Tx", &[("V", text("stored"))]);
let live = live_body(
&stale,
&LiveState {
text: "edited",
..LiveState::default()
},
)
.expect("a live body");
assert!(live.contains("(edited) Tj\n"), "{live}");
assert!(!live.contains("stored"), "{live}");
}
#[test]
fn a_live_text_field_still_obeys_the_flags_its_dictionary_sets() {
let secret = live_body(
&widget_of("Tx", &[("V", text("old")), ("Ff", Object::Int(1 << 13))]),
&LiveState {
text: "abcd",
..LiveState::default()
},
)
.expect("a live body");
assert!(secret.contains("(****) Tj\n"), "{secret}");
assert!(!secret.contains("abcd"), "{secret}");
let comb = live_body(
&widget_of(
"Tx",
&[
("V", text("z")),
("Ff", Object::Int(1 << 24)),
("MaxLen", Object::Int(4)),
],
),
&LiveState {
text: "xy",
..LiveState::default()
},
)
.expect("a live body");
assert_eq!(comb.matches(" Tj\n").count(), 2, "{comb}");
assert!(
comb.contains("(x) Tj\n") && comb.contains("(y) Tj\n"),
"{comb}"
);
}
fn editable_combo() -> Dict {
widget_of(
"Ch",
&[
("Ff", Object::Int((1 << 17) | (1 << 18))),
(
"Opt",
Object::Array(Array::of([
strings(&["a", "Apple"]),
strings(&["b", "Banana"]),
])),
),
("V", text("b")),
],
)
}
#[test]
fn a_live_combo_box_draws_the_typed_text_rather_than_an_options_label() {
let combo = editable_combo();
let stored = body(&combo).expect("a body");
assert!(stored.contains("(Banana) Tj\n"), "{stored}");
let typed = live_body(
&combo,
&LiveState {
text: "Bana",
..LiveState::default()
},
)
.expect("a live body");
assert!(typed.contains("(Bana) Tj\n"), "{typed}");
assert!(!typed.contains("(Banana) Tj\n"), "{typed}");
assert!(typed.contains("0.862745 g\n"), "{typed}");
}
fn list_of_three() -> Dict {
widget_of("Ch", &[("Opt", strings(&["Ant", "Bee", "Cat"]))])
}
#[test]
fn a_live_list_box_paints_the_sessions_selection_not_the_files() {
let list = list_of_three();
let stored = body(&list).expect("a body");
assert!(!stored.contains("0 0.2 0.443137 rg\n"), "{stored}");
let live = live_body(
&list,
&LiveState {
selected: &[1, 2],
..LiveState::default()
},
)
.expect("a live body");
assert_eq!(live.matches("0 0.2 0.443137 rg\n").count(), 2, "{live}");
assert_eq!(live.matches("1 g\n").count(), 2, "{live}");
assert_eq!(live.matches("BT\n").count(), 3, "{live}");
}
#[test]
fn a_live_selection_overrides_a_stored_one_rather_than_adding_to_it() {
let list = widget_of(
"Ch",
&[("Opt", strings(&["Ant", "Bee", "Cat"])), ("V", text("Ant"))],
);
let live = live_body(
&list,
&LiveState {
selected: &[2],
..LiveState::default()
},
)
.expect("a live body");
assert_eq!(live.matches("0 0.2 0.443137 rg\n").count(), 1, "{live}");
let band = live.find("0 0.2 0.443137 rg\n").expect("a band");
assert!(live.find("(Cat) Tj\n") > Some(band), "{live}");
assert!(live.find("(Ant) Tj\n") < Some(band), "{live}");
}
#[test]
fn a_live_list_box_starts_at_the_sessions_top_row_not_its_ti() {
let list = widget_of(
"Ch",
&[
("Opt", strings(&["Ant", "Bee", "Cat"])),
("TI", Object::Int(1)),
],
);
let stored = body(&list).expect("a body");
assert_eq!(stored.matches("BT\n").count(), 2, "{stored}");
let live = live_body(
&list,
&LiveState {
top_visible: 2,
..LiveState::default()
},
)
.expect("a live body");
assert_eq!(live.matches("BT\n").count(), 1, "{live}");
assert!(live.contains("(Cat) Tj\n"), "{live}");
assert!(!live.contains("(Bee) Tj\n"), "{live}");
let unscrolled = live_body(&list, &LiveState::default()).expect("a live body");
assert_eq!(unscrolled.matches("BT\n").count(), 3, "{unscrolled}");
}
fn first_move(stream: &str) -> (f32, f32) {
let line = stream
.lines()
.find(|line| line.ends_with(" Td"))
.unwrap_or_else(|| panic!("no Td in {stream}"));
let mut parts = line.split_whitespace();
let x: f32 = parts.next().and_then(|n| n.parse().ok()).expect("an x");
let y: f32 = parts.next().and_then(|n| n.parse().ok()).expect("a y");
(x, y)
}
#[test]
fn scrolling_shifts_the_drawn_text_by_the_scroll_offset() {
let field = widget_of("Tx", &[("V", text("Hello"))]);
let (rest_x, rest_y) = first_move(&body(&field).expect("a body"));
let scrolled = live_body(
&field,
&LiveState {
text: "Hello",
scroll: (7.0, 3.0),
..LiveState::default()
},
)
.expect("a live body");
let (x, y) = first_move(&scrolled);
assert!((x - (rest_x - 7.0)).abs() < 1e-3, "{x} against {rest_x}");
assert!((y - (rest_y - 3.0)).abs() < 1e-3, "{y} against {rest_y}");
}
#[test]
fn an_unscrolled_live_edit_draws_where_the_stored_value_would() {
let field = widget_of("Tx", &[("V", text("Hello"))]);
let stored = body(&field).expect("a body");
let live = live_body(
&field,
&LiveState {
text: "Hello",
..LiveState::default()
},
)
.expect("a live body");
assert_eq!(stored, live);
}
#[test]
fn a_scrolled_list_box_moves_its_rows_and_their_selection_bands_together() {
let live = live_body(
&list_of_three(),
&LiveState {
selected: &[0],
scroll: (0.0, 5.0),
..LiveState::default()
},
)
.expect("a live body");
let rest = live_body(
&list_of_three(),
&LiveState {
selected: &[0],
..LiveState::default()
},
)
.expect("a live body");
assert_ne!(live, rest, "a scroll must move the rows");
let (_, y) = first_move(&live);
let (_, rest_y) = first_move(&rest);
assert!((y - (rest_y - 5.0)).abs() < 1e-3, "{y} against {rest_y}");
assert_eq!(live.matches("0 0.2 0.443137 rg\n").count(), 1, "{live}");
}
#[test]
fn none_and_a_default_live_state_agree_on_every_widget_fixture() {
for (name, widget) in widget_fixtures() {
if !matches!(name, "tx_empty" | "btn") {
continue;
}
let live = LiveState::default();
assert_eq!(
body_live(&widget, None, Some(&live)),
body_live(&widget, None, None),
"{name} moved under an empty live state"
);
}
}
}