Move tester to a new iced_tester subcrate
This commit is contained in:
parent
9e81c2b9e8
commit
4f7444bddf
28 changed files with 392 additions and 355 deletions
118
tester/src/icon.rs
Normal file
118
tester/src/icon.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#![allow(unused)]
|
||||
use crate::core::Font;
|
||||
use crate::program;
|
||||
use crate::widget::{Text, text};
|
||||
|
||||
pub const FONT: &[u8] = include_bytes!("../fonts/iced_tester-icons.ttf");
|
||||
|
||||
pub fn cancel<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{2715}")
|
||||
}
|
||||
|
||||
pub fn check<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{2713}")
|
||||
}
|
||||
|
||||
pub fn floppy<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{1F4BE}")
|
||||
}
|
||||
|
||||
pub fn folder<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{1F4C1}")
|
||||
}
|
||||
|
||||
pub fn keyboard<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{2328}")
|
||||
}
|
||||
|
||||
pub fn lightbulb<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{F0EB}")
|
||||
}
|
||||
|
||||
pub fn mouse_pointer<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{F245}")
|
||||
}
|
||||
|
||||
pub fn pause<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{2389}")
|
||||
}
|
||||
|
||||
pub fn pencil<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{270E}")
|
||||
}
|
||||
|
||||
pub fn play<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{25B6}")
|
||||
}
|
||||
|
||||
pub fn record<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{26AB}")
|
||||
}
|
||||
|
||||
pub fn stop<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{25A0}")
|
||||
}
|
||||
|
||||
pub fn tape<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
icon("\u{2707}")
|
||||
}
|
||||
|
||||
fn icon<'a, Theme, Renderer>(codepoint: &'a str) -> Text<'a, Theme, Renderer>
|
||||
where
|
||||
Theme: text::Catalog + 'a,
|
||||
Renderer: program::Renderer,
|
||||
{
|
||||
text(codepoint).font(Font::with_name("iced_devtools-icons"))
|
||||
}
|
||||
856
tester/src/lib.rs
Normal file
856
tester/src/lib.rs
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
//! Record, edit, and run end-to-end tests for your iced applications.
|
||||
#![allow(missing_docs)]
|
||||
pub use iced_test as test;
|
||||
pub use iced_test::core;
|
||||
pub use iced_test::program;
|
||||
pub use iced_test::runtime;
|
||||
pub use iced_test::runtime::futures;
|
||||
pub use iced_widget as widget;
|
||||
|
||||
mod icon;
|
||||
mod recorder;
|
||||
|
||||
use recorder::recorder;
|
||||
|
||||
use crate::core::Alignment::Center;
|
||||
use crate::core::Length::Fill;
|
||||
use crate::core::alignment::Horizontal::Right;
|
||||
use crate::core::border;
|
||||
use crate::core::mouse;
|
||||
use crate::core::window;
|
||||
use crate::core::{Element, Font, Settings, Size, Theme};
|
||||
use crate::futures::futures::channel::mpsc;
|
||||
use crate::program::Program;
|
||||
use crate::runtime::task::{self, Task};
|
||||
use crate::test::emulator;
|
||||
use crate::test::ice;
|
||||
use crate::test::instruction;
|
||||
use crate::test::{Emulator, Ice, Instruction};
|
||||
use crate::widget::{
|
||||
button, center, column, combo_box, container, horizontal_space, pick_list,
|
||||
row, scrollable, text, text_editor, text_input, themer,
|
||||
};
|
||||
|
||||
/// Attaches a [`Tester`] to the given [`Program`].
|
||||
pub fn attach<P: Program + 'static>(program: P) -> Attach<P> {
|
||||
Attach { program }
|
||||
}
|
||||
|
||||
/// A [`Program`] with a [`Tester`] attached to it.
|
||||
#[derive(Debug)]
|
||||
pub struct Attach<P> {
|
||||
/// The original [`Program`] attatched to the [`Tester`].
|
||||
pub program: P,
|
||||
}
|
||||
|
||||
impl<P> Program for Attach<P>
|
||||
where
|
||||
P: Program + 'static,
|
||||
{
|
||||
type State = Tester<P>;
|
||||
type Message = Tick<P>;
|
||||
type Theme = Theme;
|
||||
type Renderer = P::Renderer;
|
||||
type Executor = P::Executor;
|
||||
|
||||
fn name() -> &'static str {
|
||||
P::name()
|
||||
}
|
||||
|
||||
fn settings(&self) -> Settings {
|
||||
let mut settings = self.program.settings();
|
||||
settings.fonts.push(icon::FONT.into());
|
||||
settings
|
||||
}
|
||||
|
||||
fn window(&self) -> Option<window::Settings> {
|
||||
self.program.window().map(|window| window::Settings {
|
||||
size: window.size + Size::new(300.0, 80.0),
|
||||
..window
|
||||
})
|
||||
}
|
||||
|
||||
fn boot(&self) -> (Self::State, Task<Self::Message>) {
|
||||
(Tester::new(&self.program), Task::none())
|
||||
}
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut Self::State,
|
||||
message: Self::Message,
|
||||
) -> Task<Self::Message> {
|
||||
state.tick(&self.program, message)
|
||||
}
|
||||
|
||||
fn view<'a>(
|
||||
&self,
|
||||
state: &'a Self::State,
|
||||
window: window::Id,
|
||||
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
|
||||
state.view(&self.program, window)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub struct Tester<P: Program> {
|
||||
viewport: Size,
|
||||
mode: emulator::Mode,
|
||||
presets: combo_box::State<String>,
|
||||
preset: Option<String>,
|
||||
instructions: Vec<Instruction>,
|
||||
state: State<P>,
|
||||
edit: Option<text_editor::Content<P::Renderer>>,
|
||||
}
|
||||
|
||||
enum State<P: Program> {
|
||||
Empty,
|
||||
Idle {
|
||||
state: P::State,
|
||||
},
|
||||
Recording {
|
||||
emulator: Emulator<P>,
|
||||
},
|
||||
Asserting {
|
||||
state: P::State,
|
||||
window: window::Id,
|
||||
last_interaction: Option<instruction::Interaction>,
|
||||
},
|
||||
Playing {
|
||||
emulator: Emulator<P>,
|
||||
current: usize,
|
||||
outcome: Outcome,
|
||||
},
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Running,
|
||||
Failed,
|
||||
Success,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
ChangeViewport(Size),
|
||||
ModeSelected(emulator::Mode),
|
||||
PresetSelected(String),
|
||||
Record,
|
||||
Stop,
|
||||
Play,
|
||||
Import,
|
||||
Export,
|
||||
Imported(Result<Ice, ice::ParseError>),
|
||||
Edit,
|
||||
Edited(text_editor::Action),
|
||||
Confirm,
|
||||
}
|
||||
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub enum Tick<P: Program> {
|
||||
Tester(Message),
|
||||
Program(P::Message),
|
||||
Emulator(emulator::Event<P>),
|
||||
Record(instruction::Interaction),
|
||||
Assert(instruction::Interaction),
|
||||
}
|
||||
|
||||
impl<P: Program + 'static> Tester<P> {
|
||||
pub fn new(program: &P) -> Self {
|
||||
let (state, _) = program.boot();
|
||||
let window = program.window().unwrap_or_default();
|
||||
|
||||
Self {
|
||||
mode: emulator::Mode::default(),
|
||||
viewport: window.size,
|
||||
presets: combo_box::State::new(
|
||||
program
|
||||
.presets()
|
||||
.iter()
|
||||
.map(program::Preset::name)
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
),
|
||||
preset: None,
|
||||
instructions: Vec::new(),
|
||||
state: State::Idle { state },
|
||||
edit: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_busy(&self) -> bool {
|
||||
matches!(
|
||||
self.state,
|
||||
State::Recording { .. }
|
||||
| State::Playing {
|
||||
outcome: Outcome::Running,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(&mut self, program: &P, message: Message) -> Task<Tick<P>> {
|
||||
match message {
|
||||
Message::ChangeViewport(viewport) => {
|
||||
self.viewport = viewport;
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::ModeSelected(mode) => {
|
||||
self.mode = mode;
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::PresetSelected(preset) => {
|
||||
self.preset = Some(preset);
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::Record => {
|
||||
self.edit = None;
|
||||
self.instructions.clear();
|
||||
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
|
||||
let emulator = Emulator::with_preset(
|
||||
sender,
|
||||
program,
|
||||
self.mode,
|
||||
self.viewport,
|
||||
self.preset(program),
|
||||
);
|
||||
|
||||
self.state = State::Recording { emulator };
|
||||
|
||||
Task::run(receiver, Tick::Emulator)
|
||||
}
|
||||
Message::Stop => {
|
||||
let State::Recording { emulator } =
|
||||
std::mem::replace(&mut self.state, State::Empty)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
while let Some(Instruction::Interact(
|
||||
instruction::Interaction::Mouse(instruction::Mouse::Move(
|
||||
_,
|
||||
)),
|
||||
)) = self.instructions.last()
|
||||
{
|
||||
let _ = self.instructions.pop();
|
||||
}
|
||||
|
||||
let (state, window) = emulator.into_state();
|
||||
|
||||
self.state = State::Asserting {
|
||||
state,
|
||||
window,
|
||||
last_interaction: None,
|
||||
};
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::Play => {
|
||||
self.confirm();
|
||||
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
|
||||
let emulator = Emulator::with_preset(
|
||||
sender,
|
||||
program,
|
||||
self.mode,
|
||||
self.viewport,
|
||||
self.preset(program),
|
||||
);
|
||||
|
||||
self.state = State::Playing {
|
||||
emulator,
|
||||
current: 0,
|
||||
outcome: Outcome::Running,
|
||||
};
|
||||
|
||||
Task::run(receiver, Tick::Emulator)
|
||||
}
|
||||
Message::Import => {
|
||||
use std::fs;
|
||||
|
||||
let import = rfd::AsyncFileDialog::new()
|
||||
.add_filter("ice", &["ice"])
|
||||
.pick_file();
|
||||
|
||||
Task::future(import)
|
||||
.and_then(|file| {
|
||||
task::blocking(move |mut sender| {
|
||||
let _ = sender.try_send(Ice::parse(
|
||||
&fs::read_to_string(file.path())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
})
|
||||
})
|
||||
.map(Message::Imported)
|
||||
.map(Tick::Tester)
|
||||
}
|
||||
Message::Export => {
|
||||
use std::fs;
|
||||
use std::thread;
|
||||
|
||||
self.confirm();
|
||||
|
||||
let ice = Ice {
|
||||
viewport: self.viewport,
|
||||
mode: self.mode,
|
||||
preset: self.preset.clone(),
|
||||
instructions: self.instructions.clone(),
|
||||
};
|
||||
|
||||
let export = rfd::AsyncFileDialog::new()
|
||||
.add_filter("ice", &["ice"])
|
||||
.save_file();
|
||||
|
||||
Task::future(async move {
|
||||
let Some(file) = export.await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = thread::spawn(move || {
|
||||
fs::write(file.path(), ice.to_string())
|
||||
});
|
||||
})
|
||||
.discard()
|
||||
}
|
||||
Message::Imported(Ok(ice)) => {
|
||||
self.viewport = ice.viewport;
|
||||
self.mode = ice.mode;
|
||||
self.preset = ice.preset;
|
||||
self.instructions = ice.instructions;
|
||||
self.edit = None;
|
||||
|
||||
let (state, _) = self
|
||||
.preset(program)
|
||||
.map(program::Preset::boot)
|
||||
.unwrap_or_else(|| program.boot());
|
||||
|
||||
self.state = State::Idle { state };
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::Edit => {
|
||||
if self.is_busy() {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.edit = Some(text_editor::Content::with_text(
|
||||
&self
|
||||
.instructions
|
||||
.iter()
|
||||
.map(Instruction::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
));
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::Edited(action) => {
|
||||
if let Some(edit) = &mut self.edit {
|
||||
edit.perform(action);
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::Confirm => {
|
||||
self.confirm();
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::Imported(Err(error)) => {
|
||||
log::error!("{error}");
|
||||
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(&mut self) {
|
||||
let Some(edit) = &mut self.edit else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.instructions = edit
|
||||
.lines()
|
||||
.filter(|line| !line.text.trim().is_empty())
|
||||
.filter_map(|line| Instruction::parse(&line.text).ok())
|
||||
.collect();
|
||||
|
||||
self.edit = None;
|
||||
}
|
||||
|
||||
fn preset<'a>(
|
||||
&self,
|
||||
program: &'a P,
|
||||
) -> Option<&'a program::Preset<P::State, P::Message>> {
|
||||
self.preset.as_ref().and_then(|preset| {
|
||||
program
|
||||
.presets()
|
||||
.iter()
|
||||
.find(|candidate| candidate.name() == preset)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, program: &P, tick: Tick<P>) -> Task<Tick<P>> {
|
||||
match tick {
|
||||
Tick::Tester(message) => self.update(program, message),
|
||||
Tick::Program(message) => {
|
||||
let State::Recording { emulator } = &mut self.state else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
emulator.update(program, message);
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Tick::Emulator(event) => {
|
||||
match &mut self.state {
|
||||
State::Recording { emulator } => {
|
||||
if let emulator::Event::Action(action) = event {
|
||||
emulator.perform(program, action);
|
||||
}
|
||||
}
|
||||
State::Playing {
|
||||
emulator,
|
||||
current,
|
||||
outcome,
|
||||
} => match event {
|
||||
emulator::Event::Action(action) => {
|
||||
emulator.perform(program, action);
|
||||
}
|
||||
emulator::Event::Failed(_instruction) => {
|
||||
*outcome = Outcome::Failed;
|
||||
}
|
||||
emulator::Event::Ready => {
|
||||
*current += 1;
|
||||
|
||||
if let Some(instruction) =
|
||||
self.instructions.get(*current - 1).cloned()
|
||||
{
|
||||
emulator.run(program, instruction);
|
||||
}
|
||||
|
||||
if *current >= self.instructions.len() {
|
||||
*outcome = Outcome::Success;
|
||||
}
|
||||
}
|
||||
},
|
||||
State::Empty
|
||||
| State::Idle { .. }
|
||||
| State::Asserting { .. } => {}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Tick::Record(interaction) => {
|
||||
let mut interaction = Some(interaction);
|
||||
|
||||
while let Some(new_interaction) = interaction.take() {
|
||||
if let Some(Instruction::Interact(last_interaction)) =
|
||||
self.instructions.pop()
|
||||
{
|
||||
let (merged_interaction, new_interaction) =
|
||||
last_interaction.merge(new_interaction);
|
||||
|
||||
if let Some(new_interaction) = new_interaction {
|
||||
self.instructions.push(Instruction::Interact(
|
||||
merged_interaction,
|
||||
));
|
||||
|
||||
self.instructions
|
||||
.push(Instruction::Interact(new_interaction));
|
||||
} else {
|
||||
interaction = Some(merged_interaction);
|
||||
}
|
||||
} else {
|
||||
self.instructions
|
||||
.push(Instruction::Interact(new_interaction));
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Tick::Assert(interaction) => {
|
||||
let State::Asserting {
|
||||
last_interaction, ..
|
||||
} = &mut self.state
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
*last_interaction =
|
||||
if let Some(last_interaction) = last_interaction.take() {
|
||||
let (merged, new) = last_interaction.merge(interaction);
|
||||
|
||||
Some(new.unwrap_or(merged))
|
||||
} else {
|
||||
Some(interaction)
|
||||
};
|
||||
|
||||
let Some(interaction) = last_interaction.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let instruction::Interaction::Mouse(
|
||||
instruction::Mouse::Click {
|
||||
button: mouse::Button::Left,
|
||||
at: Some(instruction::Target::Text(text)),
|
||||
},
|
||||
) = interaction
|
||||
else {
|
||||
*last_interaction = Some(interaction);
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
self.instructions.push(Instruction::Expect(
|
||||
instruction::Expectation::Text(text),
|
||||
));
|
||||
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view<'a>(
|
||||
&'a self,
|
||||
program: &P,
|
||||
window: window::Id,
|
||||
) -> Element<'a, Tick<P>, Theme, P::Renderer> {
|
||||
let status = {
|
||||
let (icon, label) = match &self.state {
|
||||
State::Empty | State::Idle { .. } => (text(""), "Idle"),
|
||||
State::Recording { .. } => (icon::record(), "Recording"),
|
||||
State::Asserting { .. } => (icon::lightbulb(), "Asserting"),
|
||||
State::Playing { outcome, .. } => match outcome {
|
||||
Outcome::Running => (icon::play(), "Playing"),
|
||||
Outcome::Failed => (icon::cancel(), "Failed"),
|
||||
Outcome::Success => (icon::check(), "Success"),
|
||||
},
|
||||
};
|
||||
|
||||
container(row![icon.size(14), label].align_y(Center).spacing(8))
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
container::Style {
|
||||
text_color: Some(match &self.state {
|
||||
State::Empty | State::Idle { .. } => {
|
||||
palette.background.strongest.color
|
||||
}
|
||||
State::Recording { .. } => {
|
||||
palette.danger.base.color
|
||||
}
|
||||
State::Asserting { .. } => {
|
||||
palette.warning.base.color
|
||||
}
|
||||
State::Playing { outcome, .. } => match outcome {
|
||||
Outcome::Running => theme.palette().primary,
|
||||
Outcome::Failed => theme.palette().danger,
|
||||
Outcome::Success => {
|
||||
theme
|
||||
.extended_palette()
|
||||
.success
|
||||
.strong
|
||||
.color
|
||||
}
|
||||
},
|
||||
}),
|
||||
..container::Style::default()
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let viewport = container(
|
||||
scrollable(
|
||||
container(match &self.state {
|
||||
State::Empty => horizontal_space().into(),
|
||||
State::Idle { state } => Element::from(themer(
|
||||
program.theme(state, window),
|
||||
program.view(state, window),
|
||||
))
|
||||
.map(Tick::Program),
|
||||
State::Recording { emulator } => {
|
||||
let theme = emulator.theme(program);
|
||||
let view = emulator.view(program).map(Tick::Program);
|
||||
|
||||
recorder(themer(theme, view))
|
||||
.on_record(Tick::Record)
|
||||
.into()
|
||||
}
|
||||
State::Asserting { state, window, .. } => {
|
||||
let theme = program.theme(state, *window);
|
||||
let view =
|
||||
program.view(state, *window).map(Tick::Program);
|
||||
|
||||
recorder(themer(theme, view))
|
||||
.on_record(Tick::Assert)
|
||||
.into()
|
||||
}
|
||||
State::Playing { emulator, .. } => {
|
||||
let theme = emulator.theme(program);
|
||||
let view = emulator.view(program).map(Tick::Program);
|
||||
|
||||
themer(theme, view).into()
|
||||
}
|
||||
})
|
||||
.width(self.viewport.width)
|
||||
.height(self.viewport.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
}),
|
||||
)
|
||||
.style(|theme: &Theme| {
|
||||
let palette = theme.extended_palette();
|
||||
|
||||
container::Style {
|
||||
border: border::width(2.0).color(match &self.state {
|
||||
State::Empty | State::Idle { .. } => {
|
||||
palette.background.strongest.color
|
||||
}
|
||||
State::Recording { .. } => palette.danger.base.color,
|
||||
State::Asserting { .. } => palette.warning.weak.color,
|
||||
State::Playing { outcome, .. } => match outcome {
|
||||
Outcome::Running => palette.primary.base.color,
|
||||
Outcome::Failed => palette.danger.strong.color,
|
||||
Outcome::Success => palette.success.strong.color,
|
||||
},
|
||||
}),
|
||||
..container::Style::default()
|
||||
}
|
||||
})
|
||||
.padding(10);
|
||||
|
||||
row![
|
||||
center(column![status, viewport].spacing(10).align_x(Right))
|
||||
.padding(10),
|
||||
container(self.controls().map(Tick::Tester))
|
||||
.width(250)
|
||||
.padding(10)
|
||||
.style(container::dark)
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn controls(&self) -> Element<'_, Message, Theme, P::Renderer> {
|
||||
let viewport = row![
|
||||
text_input("Width", &self.viewport.width.to_string())
|
||||
.size(14)
|
||||
.on_input(|width| Message::ChangeViewport(Size {
|
||||
width: width.parse().unwrap_or(self.viewport.width),
|
||||
..self.viewport
|
||||
})),
|
||||
text("x").size(14).font(Font::MONOSPACE),
|
||||
text_input("Height", &self.viewport.height.to_string())
|
||||
.size(14)
|
||||
.on_input(|height| Message::ChangeViewport(Size {
|
||||
height: height.parse().unwrap_or(self.viewport.height),
|
||||
..self.viewport
|
||||
})),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Center);
|
||||
|
||||
let preset = combo_box(
|
||||
&self.presets,
|
||||
"Default",
|
||||
self.preset.as_ref(),
|
||||
Message::PresetSelected,
|
||||
)
|
||||
.size(14)
|
||||
.width(Fill);
|
||||
|
||||
let mode = pick_list(
|
||||
emulator::Mode::ALL,
|
||||
Some(self.mode),
|
||||
Message::ModeSelected,
|
||||
)
|
||||
.text_size(14)
|
||||
.width(Fill);
|
||||
|
||||
let player = {
|
||||
let instructions = if let Some(edit) = &self.edit {
|
||||
text_editor(edit)
|
||||
.size(12)
|
||||
.height(Fill)
|
||||
.font(Font::MONOSPACE)
|
||||
.on_action(Message::Edited)
|
||||
.into()
|
||||
} else if self.instructions.is_empty() {
|
||||
Element::from(center(
|
||||
text("No instructions recorded yet!")
|
||||
.size(14)
|
||||
.font(Font::MONOSPACE)
|
||||
.width(Fill)
|
||||
.center(),
|
||||
))
|
||||
} else {
|
||||
scrollable(
|
||||
column(self.instructions.iter().enumerate().map(
|
||||
|(i, instruction)| {
|
||||
text(instruction.to_string())
|
||||
.wrapping(text::Wrapping::None) // TODO: Ellipsize?
|
||||
.size(10)
|
||||
.font(Font::MONOSPACE)
|
||||
.style(move |theme: &Theme| text::Style {
|
||||
color: match &self.state {
|
||||
State::Playing {
|
||||
current,
|
||||
outcome,
|
||||
..
|
||||
} => {
|
||||
if *current == i + 1 {
|
||||
Some(match outcome {
|
||||
Outcome::Running => {
|
||||
theme.palette().primary
|
||||
}
|
||||
Outcome::Failed => {
|
||||
theme
|
||||
.extended_palette()
|
||||
.danger
|
||||
.strong
|
||||
.color
|
||||
}
|
||||
Outcome::Success => {
|
||||
theme
|
||||
.extended_palette()
|
||||
.success
|
||||
.strong
|
||||
.color
|
||||
}
|
||||
})
|
||||
} else if *current > i + 1 {
|
||||
Some(
|
||||
theme
|
||||
.extended_palette()
|
||||
.success
|
||||
.strong
|
||||
.color,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
.into()
|
||||
},
|
||||
))
|
||||
.spacing(5),
|
||||
)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.spacing(5)
|
||||
.into()
|
||||
};
|
||||
|
||||
let control = |icon: text::Text<'static, _, _>| {
|
||||
button(icon.size(14).width(Fill).height(Fill).center())
|
||||
};
|
||||
|
||||
let play = control(icon::play()).on_press_maybe(
|
||||
(!matches!(self.state, State::Recording { .. })
|
||||
&& !self.instructions.is_empty())
|
||||
.then_some(Message::Play),
|
||||
);
|
||||
|
||||
let record = if let State::Recording { .. } = &self.state {
|
||||
control(icon::stop())
|
||||
.on_press(Message::Stop)
|
||||
.style(button::success)
|
||||
} else {
|
||||
control(icon::record())
|
||||
.on_press_maybe(
|
||||
(!self.is_busy()).then_some(Message::Record),
|
||||
)
|
||||
.style(button::danger)
|
||||
};
|
||||
|
||||
let import = control(icon::folder())
|
||||
.on_press_maybe((!self.is_busy()).then_some(Message::Import))
|
||||
.style(button::secondary);
|
||||
|
||||
let export = control(icon::floppy())
|
||||
.on_press_maybe(
|
||||
(!matches!(self.state, State::Recording { .. })
|
||||
&& !self.instructions.is_empty())
|
||||
.then_some(Message::Export),
|
||||
)
|
||||
.style(button::success);
|
||||
|
||||
let controls =
|
||||
row![import, export, play, record].height(30).spacing(10);
|
||||
|
||||
column![instructions, controls].spacing(10).align_x(Center)
|
||||
};
|
||||
|
||||
let edit = if self.is_busy() {
|
||||
Element::from(horizontal_space())
|
||||
} else if self.edit.is_none() {
|
||||
button(icon::pencil().size(14))
|
||||
.padding(0)
|
||||
.on_press(Message::Edit)
|
||||
.style(button::text)
|
||||
.into()
|
||||
} else {
|
||||
button(icon::check().size(14))
|
||||
.padding(0)
|
||||
.on_press(Message::Confirm)
|
||||
.style(button::text)
|
||||
.into()
|
||||
};
|
||||
|
||||
column![
|
||||
labeled("Viewport", viewport),
|
||||
labeled("Mode", mode),
|
||||
labeled("Preset", preset),
|
||||
labeled_with("Instructions", edit, player)
|
||||
]
|
||||
.spacing(10)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled<'a, Message, Renderer>(
|
||||
fragment: impl text::IntoFragment<'a>,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Renderer: program::Renderer + 'a,
|
||||
{
|
||||
column![
|
||||
text(fragment).size(14).font(Font::MONOSPACE),
|
||||
content.into()
|
||||
]
|
||||
.spacing(5)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn labeled_with<'a, Message, Renderer>(
|
||||
fragment: impl text::IntoFragment<'a>,
|
||||
control: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Renderer: program::Renderer + 'a,
|
||||
{
|
||||
column![
|
||||
row![
|
||||
text(fragment).size(14).font(Font::MONOSPACE),
|
||||
horizontal_space(),
|
||||
control.into()
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(Center),
|
||||
content.into()
|
||||
]
|
||||
.spacing(5)
|
||||
.into()
|
||||
}
|
||||
490
tester/src/recorder.rs
Normal file
490
tester/src/recorder.rs
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
use crate::core::layout;
|
||||
use crate::core::mouse;
|
||||
use crate::core::overlay;
|
||||
use crate::core::renderer;
|
||||
use crate::core::widget;
|
||||
use crate::core::widget::operation;
|
||||
use crate::core::widget::tree;
|
||||
use crate::core::{
|
||||
self, Clipboard, Element, Event, Layout, Length, Point, Rectangle, Shell,
|
||||
Size, Theme, Vector, Widget,
|
||||
};
|
||||
use crate::test::Selector;
|
||||
use crate::test::instruction::{Interaction, Mouse, Target};
|
||||
use crate::test::selector::target;
|
||||
|
||||
pub fn recorder<'a, Message, Renderer>(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Recorder<'a, Message, Renderer> {
|
||||
Recorder::new(content)
|
||||
}
|
||||
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub struct Recorder<'a, Message, Renderer> {
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
on_record: Option<Box<dyn Fn(Interaction) -> Message + 'a>>,
|
||||
has_overlay: bool,
|
||||
}
|
||||
|
||||
impl<'a, Message, Renderer> Recorder<'a, Message, Renderer> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
on_record: None,
|
||||
has_overlay: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_record(
|
||||
mut self,
|
||||
on_record: impl Fn(Interaction) -> Message + 'a,
|
||||
) -> Self {
|
||||
self.on_record = Some(Box::new(on_record));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
last_hovered: Option<Rectangle>,
|
||||
last_hovered_overlay: Option<Rectangle>,
|
||||
}
|
||||
|
||||
impl<Message, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Recorder<'_, Message, Renderer>
|
||||
where
|
||||
Renderer: core::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State {
|
||||
last_hovered: None,
|
||||
last_hovered_overlay: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.content)]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut tree::Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.has_overlay
|
||||
&& let Some(on_record) = &self.on_record
|
||||
{
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
record(
|
||||
event,
|
||||
cursor,
|
||||
shell,
|
||||
layout.bounds(),
|
||||
&mut state.last_hovered,
|
||||
on_record,
|
||||
|operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(
|
||||
&mut tree.children[0],
|
||||
renderer,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
|
||||
let state = tree.state.downcast_ref::<State>();
|
||||
|
||||
let Some(last_hovered) = &state.last_hovered else {
|
||||
return;
|
||||
};
|
||||
|
||||
let palette = theme.palette();
|
||||
|
||||
renderer.with_layer(*viewport, |renderer| {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: *last_hovered,
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
palette.primary.scale_alpha(0.7),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
_viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.has_overlay = false;
|
||||
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
&layout.bounds(),
|
||||
translation,
|
||||
)
|
||||
.map(|raw| {
|
||||
self.has_overlay = true;
|
||||
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
overlay::Element::new(Box::new(Overlay {
|
||||
raw,
|
||||
bounds: layout.bounds(),
|
||||
last_hovered: &mut state.last_hovered_overlay,
|
||||
on_record: self.on_record.as_deref(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Renderer> From<Recorder<'a, Message, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: core::Renderer + 'a,
|
||||
{
|
||||
fn from(recorder: Recorder<'a, Message, Renderer>) -> Self {
|
||||
Element::new(recorder)
|
||||
}
|
||||
}
|
||||
|
||||
struct Overlay<'a, Message, Renderer> {
|
||||
raw: overlay::Element<'a, Message, Theme, Renderer>,
|
||||
bounds: Rectangle,
|
||||
last_hovered: &'a mut Option<Rectangle>,
|
||||
on_record: Option<&'a dyn Fn(Interaction) -> Message>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Renderer> core::Overlay<Message, Theme, Renderer>
|
||||
for Overlay<'a, Message, Renderer>
|
||||
where
|
||||
Renderer: core::Renderer + 'a,
|
||||
{
|
||||
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||
self.raw.as_overlay_mut().layout(renderer, bounds)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
self.raw
|
||||
.as_overlay()
|
||||
.draw(renderer, theme, style, layout, cursor);
|
||||
|
||||
let Some(last_hovered) = &self.last_hovered else {
|
||||
return;
|
||||
};
|
||||
|
||||
let palette = theme.palette();
|
||||
|
||||
renderer.with_layer(self.bounds, |renderer| {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: *last_hovered,
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
palette.primary.scale_alpha(0.7),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.raw
|
||||
.as_overlay_mut()
|
||||
.operate(layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(on_event) = &self.on_record {
|
||||
record(
|
||||
event,
|
||||
cursor,
|
||||
shell,
|
||||
self.bounds,
|
||||
self.last_hovered,
|
||||
on_event,
|
||||
|operation| {
|
||||
self.raw
|
||||
.as_overlay_mut()
|
||||
.operate(layout, renderer, operation);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
self.raw
|
||||
.as_overlay_mut()
|
||||
.update(event, layout, cursor, renderer, clipboard, shell);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.raw
|
||||
.as_overlay()
|
||||
.mouse_interaction(layout, cursor, renderer)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.raw
|
||||
.as_overlay_mut()
|
||||
.overlay(layout, renderer)
|
||||
.map(|raw| {
|
||||
overlay::Element::new(Box::new(Overlay {
|
||||
raw,
|
||||
bounds: self.bounds,
|
||||
last_hovered: self.last_hovered,
|
||||
on_record: self.on_record,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn index(&self) -> f32 {
|
||||
self.raw.as_overlay().index()
|
||||
}
|
||||
}
|
||||
|
||||
fn record<Message>(
|
||||
event: &Event,
|
||||
cursor: mouse::Cursor,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
bounds: Rectangle,
|
||||
last_hovered: &mut Option<Rectangle>,
|
||||
on_record: impl Fn(Interaction) -> Message,
|
||||
operate: impl FnMut(&mut dyn widget::Operation),
|
||||
) {
|
||||
if let Event::Mouse(_) = event
|
||||
&& !cursor.is_over(bounds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let interaction =
|
||||
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
Interaction::from_event(&Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: *position - (bounds.position() - Point::ORIGIN),
|
||||
}))
|
||||
} else {
|
||||
Interaction::from_event(event)
|
||||
};
|
||||
|
||||
let Some(mut interaction) = interaction else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Interaction::Mouse(
|
||||
Mouse::Move(at)
|
||||
| Mouse::Press { at: Some(at), .. }
|
||||
| Mouse::Release { at: Some(at), .. }
|
||||
| Mouse::Click { at: Some(at), .. },
|
||||
) = &mut interaction
|
||||
else {
|
||||
shell.publish(on_record(interaction));
|
||||
return;
|
||||
};
|
||||
|
||||
let Target::Point(position) = *at else {
|
||||
shell.publish(on_record(interaction));
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some((content, visible_bounds)) =
|
||||
find_text(position + (bounds.position() - Point::ORIGIN), operate)
|
||||
{
|
||||
*at = Target::Text(content);
|
||||
*last_hovered = visible_bounds;
|
||||
} else {
|
||||
*last_hovered = None;
|
||||
}
|
||||
|
||||
shell.publish(on_record(interaction));
|
||||
}
|
||||
|
||||
fn find_text(
|
||||
position: Point,
|
||||
mut operate: impl FnMut(&mut dyn widget::Operation),
|
||||
) -> Option<(String, Option<Rectangle>)> {
|
||||
use widget::Operation;
|
||||
|
||||
let mut by_position = position.find_all();
|
||||
operate(&mut operation::black_box(&mut by_position));
|
||||
|
||||
let operation::Outcome::Some(targets) = by_position.finish() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let (content, visible_bounds) =
|
||||
targets.into_iter().rev().find_map(|target| {
|
||||
if let target::Match::Text {
|
||||
content,
|
||||
visible_bounds,
|
||||
..
|
||||
}
|
||||
| target::Match::TextInput {
|
||||
content,
|
||||
visible_bounds,
|
||||
..
|
||||
} = target
|
||||
{
|
||||
Some((content, visible_bounds))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut by_text = content.clone().find_all();
|
||||
operate(&mut operation::black_box(&mut by_text));
|
||||
|
||||
let operation::Outcome::Some(texts) = by_text.finish() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if texts.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((content, visible_bounds))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue