1use std::error::Error;
4use std::fmt;
5use std::future::{Future, IntoFuture};
6use std::pin::Pin;
7use std::time::Duration;
8
9use rmux_proto::RmuxError as ProtoError;
10use tokio::time::Instant;
11
12use crate::{Pane, PaneSnapshot, Result, RmuxError};
13
14use super::{resolved_wait_timeout, TEXT_POLL_INTERVAL};
15
16#[cfg(feature = "regex")]
17const REGEX_SIZE_LIMIT: usize = 1_000_000;
18
19#[derive(Debug, Clone, Copy)]
21pub struct VisibleTextExpectation<'a> {
22 pane: &'a Pane,
23}
24
25impl<'a> VisibleTextExpectation<'a> {
26 pub(crate) const fn new(pane: &'a Pane) -> Self {
27 Self { pane }
28 }
29
30 pub fn to_contain(self, needle: impl Into<String>) -> VisibleTextWait<'a> {
32 VisibleTextWait::new(self.pane, VisibleTextMatcherSpec::Contains(needle.into()))
33 }
34
35 pub fn not_to_contain(self, needle: impl Into<String>) -> VisibleTextWait<'a> {
41 VisibleTextWait::new(
42 self.pane,
43 VisibleTextMatcherSpec::NotContains(needle.into()),
44 )
45 }
46
47 pub fn to_match_any<I, S>(self, needles: I) -> VisibleTextWait<'a>
49 where
50 I: IntoIterator<Item = S>,
51 S: Into<String>,
52 {
53 VisibleTextWait::new(
54 self.pane,
55 VisibleTextMatcherSpec::Any(needles.into_iter().map(Into::into).collect()),
56 )
57 }
58
59 pub fn to_match_all<I, S>(self, needles: I) -> VisibleTextWait<'a>
62 where
63 I: IntoIterator<Item = S>,
64 S: Into<String>,
65 {
66 VisibleTextWait::new(
67 self.pane,
68 VisibleTextMatcherSpec::All(needles.into_iter().map(Into::into).collect()),
69 )
70 }
71
72 #[cfg(feature = "regex")]
76 pub fn to_match(self, pattern: impl Into<String>) -> VisibleTextWait<'a> {
77 self.to_match_regex(pattern)
78 }
79
80 #[cfg(feature = "regex")]
84 pub fn to_match_regex(self, pattern: impl Into<String>) -> VisibleTextWait<'a> {
85 VisibleTextWait::new(self.pane, VisibleTextMatcherSpec::Regex(pattern.into()))
86 }
87
88 #[cfg(feature = "regex")]
95 pub fn not_to_match_regex(self, pattern: impl Into<String>) -> VisibleTextWait<'a> {
96 VisibleTextWait::new(self.pane, VisibleTextMatcherSpec::NotRegex(pattern.into()))
97 }
98
99 #[cfg(feature = "regex")]
103 pub fn to_match_any_regex<I, S>(self, patterns: I) -> VisibleTextWait<'a>
104 where
105 I: IntoIterator<Item = S>,
106 S: Into<String>,
107 {
108 VisibleTextWait::new(
109 self.pane,
110 VisibleTextMatcherSpec::RegexAny(patterns.into_iter().map(Into::into).collect()),
111 )
112 }
113
114 #[cfg(feature = "regex")]
118 pub fn to_match_all_regex<I, S>(self, patterns: I) -> VisibleTextWait<'a>
119 where
120 I: IntoIterator<Item = S>,
121 S: Into<String>,
122 {
123 VisibleTextWait::new(
124 self.pane,
125 VisibleTextMatcherSpec::RegexAll(patterns.into_iter().map(Into::into).collect()),
126 )
127 }
128}
129
130#[derive(Debug)]
132#[must_use = "visible text waits do nothing unless awaited"]
133pub struct VisibleTextWait<'a> {
134 pane: &'a Pane,
135 matcher: VisibleTextMatcherSpec,
136 timeout: Option<Duration>,
137 poll_interval: Duration,
138}
139
140impl<'a> VisibleTextWait<'a> {
141 fn new(pane: &'a Pane, matcher: VisibleTextMatcherSpec) -> Self {
142 Self {
143 pane,
144 matcher,
145 timeout: None,
146 poll_interval: TEXT_POLL_INTERVAL,
147 }
148 }
149
150 pub const fn timeout(mut self, timeout: Duration) -> Self {
152 self.timeout = Some(timeout);
153 self
154 }
155
156 pub const fn poll_interval(mut self, interval: Duration) -> Self {
158 self.poll_interval = interval;
159 self
160 }
161
162 async fn run(self) -> Result<PaneSnapshot> {
163 let matcher = self.matcher.compile()?;
164 let timeout = self
165 .timeout
166 .or_else(|| resolved_wait_timeout(self.pane.configured_default_timeout()));
167 let deadline = timeout.map(|timeout| Instant::now() + timeout);
168 loop {
169 let snapshot = self.pane.snapshot().await?;
170 if matcher.matches(&snapshot.visible_text()) {
171 return Ok(snapshot);
172 }
173
174 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
175 return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
176 matcher.describe(),
177 timeout.expect("deadline implies timeout"),
178 snapshot,
179 )));
180 }
181
182 sleep_until_next_poll(deadline, self.poll_interval).await;
183
184 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
185 return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
186 matcher.describe(),
187 timeout.expect("deadline implies timeout"),
188 snapshot,
189 )));
190 }
191 }
192 }
193}
194
195impl<'a> IntoFuture for VisibleTextWait<'a> {
196 type Output = Result<PaneSnapshot>;
197 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send + 'a>>;
198
199 fn into_future(self) -> Self::IntoFuture {
200 Box::pin(self.run())
201 }
202}
203
204#[derive(Debug)]
205enum VisibleTextMatcherSpec {
206 Contains(String),
207 NotContains(String),
208 Any(Vec<String>),
209 All(Vec<String>),
210 #[cfg(feature = "regex")]
211 Regex(String),
212 #[cfg(feature = "regex")]
213 NotRegex(String),
214 #[cfg(feature = "regex")]
215 RegexAny(Vec<String>),
216 #[cfg(feature = "regex")]
217 RegexAll(Vec<String>),
218}
219
220impl VisibleTextMatcherSpec {
221 fn compile(self) -> Result<VisibleTextMatcher> {
222 let invalid = match self {
223 Self::Contains(ref value) | Self::NotContains(ref value) => value.is_empty(),
224 Self::Any(ref values) | Self::All(ref values) => {
225 values.is_empty() || values.iter().any(String::is_empty)
226 }
227 #[cfg(feature = "regex")]
228 Self::Regex(ref value) | Self::NotRegex(ref value) => value.is_empty(),
229 #[cfg(feature = "regex")]
230 Self::RegexAny(ref values) | Self::RegexAll(ref values) => {
231 values.is_empty() || values.iter().any(String::is_empty)
232 }
233 };
234 if invalid {
235 return Err(RmuxError::protocol(ProtoError::Server(
236 "visible text wait patterns must not be empty".to_owned(),
237 )));
238 }
239
240 match self {
241 Self::Contains(value) => Ok(VisibleTextMatcher::Contains(value)),
242 Self::NotContains(value) => Ok(VisibleTextMatcher::NotContains(value)),
243 Self::Any(values) => Ok(VisibleTextMatcher::Any(values)),
244 Self::All(values) => Ok(VisibleTextMatcher::All(values)),
245 #[cfg(feature = "regex")]
246 Self::Regex(pattern) => Ok(VisibleTextMatcher::Regex(compile_regex(pattern)?)),
247 #[cfg(feature = "regex")]
248 Self::NotRegex(pattern) => Ok(VisibleTextMatcher::NotRegex(compile_regex(pattern)?)),
249 #[cfg(feature = "regex")]
250 Self::RegexAny(patterns) => compile_regexes(patterns).map(VisibleTextMatcher::RegexAny),
251 #[cfg(feature = "regex")]
252 Self::RegexAll(patterns) => compile_regexes(patterns).map(VisibleTextMatcher::RegexAll),
253 }
254 }
255}
256
257#[derive(Debug)]
258enum VisibleTextMatcher {
259 Contains(String),
260 NotContains(String),
261 Any(Vec<String>),
262 All(Vec<String>),
263 #[cfg(feature = "regex")]
264 Regex(regex::Regex),
265 #[cfg(feature = "regex")]
266 NotRegex(regex::Regex),
267 #[cfg(feature = "regex")]
268 RegexAny(Vec<regex::Regex>),
269 #[cfg(feature = "regex")]
270 RegexAll(Vec<regex::Regex>),
271}
272
273impl VisibleTextMatcher {
274 fn matches(&self, visible_text: &str) -> bool {
275 match self {
276 Self::Contains(value) => visible_text.contains(value),
277 Self::NotContains(value) => !visible_text.contains(value),
278 Self::Any(values) => values.iter().any(|value| visible_text.contains(value)),
279 Self::All(values) => values.iter().all(|value| visible_text.contains(value)),
280 #[cfg(feature = "regex")]
281 Self::Regex(pattern) => pattern.is_match(visible_text),
282 #[cfg(feature = "regex")]
283 Self::NotRegex(pattern) => !pattern.is_match(visible_text),
284 #[cfg(feature = "regex")]
285 Self::RegexAny(patterns) => patterns
286 .iter()
287 .any(|pattern| pattern.is_match(visible_text)),
288 #[cfg(feature = "regex")]
289 Self::RegexAll(patterns) => patterns
290 .iter()
291 .all(|pattern| pattern.is_match(visible_text)),
292 }
293 }
294
295 fn describe(&self) -> String {
296 match self {
297 Self::Contains(value) => format!("contain `{value}`"),
298 Self::NotContains(value) => format!("not contain `{value}`"),
299 Self::Any(values) => format!("match any of {}", render_patterns(values)),
300 Self::All(values) => format!("match all of {}", render_patterns(values)),
301 #[cfg(feature = "regex")]
302 Self::Regex(pattern) => format!("match regex `{}`", pattern.as_str()),
303 #[cfg(feature = "regex")]
304 Self::NotRegex(pattern) => format!("not match regex `{}`", pattern.as_str()),
305 #[cfg(feature = "regex")]
306 Self::RegexAny(patterns) => {
307 format!("match any regex of {}", render_regex_patterns(patterns))
308 }
309 #[cfg(feature = "regex")]
310 Self::RegexAll(patterns) => {
311 format!("match all regex of {}", render_regex_patterns(patterns))
312 }
313 }
314 }
315}
316
317#[derive(Debug)]
319pub struct WaitTimeoutError {
320 matcher: String,
321 timeout: Duration,
322 last_snapshot: PaneSnapshot,
323}
324
325impl WaitTimeoutError {
326 pub(crate) fn new(matcher: String, timeout: Duration, last_snapshot: PaneSnapshot) -> Self {
327 Self {
328 matcher,
329 timeout,
330 last_snapshot,
331 }
332 }
333
334 #[must_use]
336 pub fn matcher(&self) -> &str {
337 &self.matcher
338 }
339
340 #[must_use]
342 pub const fn timeout(&self) -> Duration {
343 self.timeout
344 }
345
346 #[must_use]
348 pub const fn last_snapshot(&self) -> &PaneSnapshot {
349 &self.last_snapshot
350 }
351
352 #[must_use]
354 pub fn last_visible_text(&self) -> String {
355 self.last_snapshot.visible_text()
356 }
357}
358
359impl fmt::Display for WaitTimeoutError {
360 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
361 write!(
362 formatter,
363 "timed out after {}s waiting for visible text to {}; last visible screen:\n{}",
364 self.timeout.as_secs_f32(),
365 self.matcher,
366 self.last_snapshot.visible_text()
367 )
368 }
369}
370
371impl Error for WaitTimeoutError {}
372
373async fn sleep_until_next_poll(deadline: Option<Instant>, poll_interval: Duration) {
374 let Some(deadline) = deadline else {
375 tokio::time::sleep(poll_interval).await;
376 return;
377 };
378
379 let now = Instant::now();
380 if now >= deadline {
381 return;
382 }
383 tokio::time::sleep(poll_interval.min(deadline - now)).await;
384}
385
386fn render_patterns(patterns: &[String]) -> String {
387 patterns
388 .iter()
389 .map(|pattern| format!("`{pattern}`"))
390 .collect::<Vec<_>>()
391 .join(", ")
392}
393
394#[cfg(feature = "regex")]
395fn compile_regex(pattern: String) -> Result<regex::Regex> {
396 regex::RegexBuilder::new(&pattern)
397 .size_limit(REGEX_SIZE_LIMIT)
398 .dfa_size_limit(REGEX_SIZE_LIMIT)
399 .build()
400 .map_err(|error| RmuxError::invalid_regex(pattern, error.to_string()))
401}
402
403#[cfg(feature = "regex")]
404fn compile_regexes(patterns: Vec<String>) -> Result<Vec<regex::Regex>> {
405 patterns.into_iter().map(compile_regex).collect()
406}
407
408#[cfg(feature = "regex")]
409fn render_regex_patterns(patterns: &[regex::Regex]) -> String {
410 patterns
411 .iter()
412 .map(|pattern| format!("`{}`", pattern.as_str()))
413 .collect::<Vec<_>>()
414 .join(", ")
415}