1use std::future::{Future, IntoFuture};
8use std::pin::Pin;
9use std::time::{Duration, Instant};
10
11use crate::{Pane, PaneSnapshot, PaneTextMatch, Result, RmuxError, WaitTimeoutError};
12
13mod query;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum LocatorState {
19 Visible,
21 Hidden,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28pub enum LocatorText {
29 Literal(String),
31 #[cfg(feature = "regex")]
33 Regex(String),
34}
35
36impl From<&str> for LocatorText {
37 fn from(value: &str) -> Self {
38 Self::Literal(value.to_owned())
39 }
40}
41
42impl From<String> for LocatorText {
43 fn from(value: String) -> Self {
44 Self::Literal(value)
45 }
46}
47
48#[cfg(feature = "regex")]
49impl From<regex::Regex> for LocatorText {
50 fn from(value: regex::Regex) -> Self {
51 Self::Regex(value.as_str().to_owned())
52 }
53}
54
55#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
57pub struct LocatorFilter {
58 pub has_text: Option<String>,
60 pub has_not_text: Option<String>,
62 pub visible: Option<bool>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72pub struct LocatorMatch {
73 pub text_match: PaneTextMatch,
75}
76
77#[derive(Debug, Clone)]
79#[must_use = "locators do nothing unless an action, assertion, or wait is awaited"]
80pub struct Locator {
81 pane: Pane,
82 query: LocatorQuery,
83 selection: LocatorSelection,
84 filters: LocatorFilter,
85 timeout: Option<Duration>,
86 poll_interval: Duration,
87 invalid_reason: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Hash)]
91enum LocatorQuery {
92 Text(LocatorText),
93 Or(Box<LocatorQuery>, Box<LocatorQuery>),
94 And(Box<LocatorQuery>, Box<LocatorQuery>),
95}
96
97#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
98enum LocatorSelection {
99 #[default]
100 Strict,
101 First,
102 Last,
103 Nth(usize),
104}
105
106impl Locator {
107 pub(crate) fn get_by_text(pane: Pane, text: impl Into<LocatorText>) -> Self {
108 Self::new(pane, LocatorQuery::Text(text.into()))
109 }
110
111 pub(crate) fn parse(pane: Pane, selector: impl AsRef<str>) -> Self {
112 let selector = selector.as_ref();
113 let text = selector.strip_prefix("text=").unwrap_or(selector);
114 Self::get_by_text(pane, text)
115 }
116
117 fn new(pane: Pane, query: LocatorQuery) -> Self {
118 Self {
119 pane,
120 query,
121 selection: LocatorSelection::Strict,
122 filters: LocatorFilter::default(),
123 timeout: None,
124 poll_interval: crate::wait::TEXT_POLL_INTERVAL,
125 invalid_reason: None,
126 }
127 }
128
129 pub const fn first(mut self) -> Self {
131 self.selection = LocatorSelection::First;
132 self
133 }
134
135 pub const fn last(mut self) -> Self {
137 self.selection = LocatorSelection::Last;
138 self
139 }
140
141 pub const fn nth(mut self, index: usize) -> Self {
143 self.selection = LocatorSelection::Nth(index);
144 self
145 }
146
147 pub fn filter(mut self, filter: LocatorFilter) -> Self {
149 self.filters = filter;
150 self
151 }
152
153 pub fn or(self, other: Self) -> Self {
160 self.combine(other, LocatorCombiner::Or)
161 }
162
163 pub fn and(self, other: Self) -> Self {
169 self.combine(other, LocatorCombiner::And)
170 }
171
172 pub const fn timeout(mut self, timeout: Duration) -> Self {
174 self.timeout = Some(timeout);
175 self
176 }
177
178 pub const fn poll_interval(mut self, interval: Duration) -> Self {
180 self.poll_interval = interval;
181 self
182 }
183
184 pub fn wait_for(self) -> LocatorWait {
186 self.wait_for_state(LocatorState::Visible)
187 }
188
189 pub fn wait_for_state(self, state: LocatorState) -> LocatorWait {
191 LocatorWait {
192 locator: self,
193 state,
194 }
195 }
196
197 pub fn expect(self) -> LocatorExpectation {
199 LocatorExpectation { locator: self }
200 }
201
202 pub(crate) async fn resolve(&self, snapshot: &PaneSnapshot) -> Result<Vec<LocatorMatch>> {
203 if let Some(reason) = &self.invalid_reason {
204 return Err(RmuxError::protocol(rmux_proto::RmuxError::Server(
205 reason.clone(),
206 )));
207 }
208 let mut matches = query::evaluate_query(&self.query, snapshot)?;
209 query::apply_filter(&mut matches, &self.filters)?;
210 Ok(query::apply_selection(matches, self.selection))
211 }
212
213 pub(crate) async fn resolve_strict_with_wait(&self) -> Result<(PaneSnapshot, LocatorMatch)> {
214 let timeout = self
215 .timeout
216 .or_else(|| crate::wait::resolved_wait_timeout(self.pane.configured_default_timeout()));
217 let deadline = timeout.map(|timeout| Instant::now() + timeout);
218 loop {
219 let snapshot = self.pane.snapshot().await?;
220 let matches = self.resolve(&snapshot).await?;
221 match matches.len() {
222 1 => {
223 let item = matches
224 .into_iter()
225 .next()
226 .expect("single match length guarantees one entry");
227 return Ok((snapshot, item));
228 }
229 0 => {}
230 count => return Err(strict_locator_error(count, self.describe(), &snapshot)),
231 }
232 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
233 return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
234 format!("strict locator {}", self.describe()),
235 timeout.expect("deadline implies timeout"),
236 snapshot,
237 )));
238 }
239 sleep_until_next_poll(deadline, self.poll_interval).await;
240 }
241 }
242
243 pub(crate) fn pane(&self) -> &Pane {
244 &self.pane
245 }
246
247 fn combine(self, other: Self, combiner: LocatorCombiner) -> Self {
248 let invalid_reason = if self.pane.target() != other.pane.target()
249 || self.pane.endpoint() != other.pane.endpoint()
250 {
251 Some(format!(
252 "locator combination requires the same pane endpoint and target, got {} and {}",
253 self.pane.target().to_proto(),
254 other.pane.target().to_proto()
255 ))
256 } else if let Some(reason) = self.invalid_reason.clone() {
257 Some(reason)
258 } else if let Some(reason) = other.invalid_reason.clone() {
259 Some(reason)
260 } else if !self.is_plain_combinable() || !other.is_plain_combinable() {
261 Some(format!(
262 "locator.{} only supports plain locators; apply first/last/nth, filters, timeout, or poll_interval after combining",
263 combiner.name()
264 ))
265 } else {
266 None
267 };
268 let query = match combiner {
269 LocatorCombiner::Or => LocatorQuery::Or(Box::new(self.query), Box::new(other.query)),
270 LocatorCombiner::And => LocatorQuery::And(Box::new(self.query), Box::new(other.query)),
271 };
272 Self {
273 pane: self.pane,
274 query,
275 selection: LocatorSelection::Strict,
276 filters: LocatorFilter::default(),
277 timeout: None,
278 poll_interval: crate::wait::TEXT_POLL_INTERVAL,
279 invalid_reason,
280 }
281 }
282
283 fn describe(&self) -> String {
284 query::describe_query(&self.query)
285 }
286
287 fn is_plain_combinable(&self) -> bool {
288 self.selection == LocatorSelection::Strict
289 && self.filters == LocatorFilter::default()
290 && self.timeout.is_none()
291 && self.poll_interval == crate::wait::TEXT_POLL_INTERVAL
292 && self.invalid_reason.is_none()
293 }
294}
295
296#[derive(Debug, Clone, Copy)]
297enum LocatorCombiner {
298 Or,
299 And,
300}
301
302impl LocatorCombiner {
303 const fn name(self) -> &'static str {
304 match self {
305 Self::Or => "or",
306 Self::And => "and",
307 }
308 }
309}
310
311impl Pane {
312 pub fn get_by_text(&self, text: impl Into<LocatorText>) -> Locator {
317 Locator::get_by_text(self.clone(), text)
318 }
319
320 pub fn locator(&self, selector: impl AsRef<str>) -> Locator {
325 Locator::parse(self.clone(), selector)
326 }
327}
328
329#[derive(Debug)]
331#[must_use = "locator waits do nothing unless awaited"]
332pub struct LocatorWait {
333 locator: Locator,
334 state: LocatorState,
335}
336
337impl LocatorWait {
338 pub fn timeout(mut self, timeout: Duration) -> Self {
340 self.locator.timeout = Some(timeout);
341 self
342 }
343
344 async fn run(self) -> Result<PaneSnapshot> {
345 wait_for_locator_state(self.locator, self.state).await
346 }
347}
348
349impl IntoFuture for LocatorWait {
350 type Output = Result<PaneSnapshot>;
351 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
352
353 fn into_future(self) -> Self::IntoFuture {
354 Box::pin(self.run())
355 }
356}
357
358#[derive(Debug)]
360#[must_use = "locator assertions do nothing unless awaited"]
361pub struct LocatorExpectation {
362 locator: Locator,
363}
364
365impl LocatorExpectation {
366 pub fn to_be_visible(self) -> LocatorAssertion {
368 LocatorAssertion::new(self.locator, LocatorAssertionKind::Visible)
369 }
370
371 pub fn to_be_hidden(self) -> LocatorAssertion {
373 LocatorAssertion::new(self.locator, LocatorAssertionKind::Hidden)
374 }
375
376 pub fn to_contain_text(self, text: impl Into<String>) -> LocatorAssertion {
378 LocatorAssertion::new(
379 self.locator,
380 LocatorAssertionKind::ContainsText(text.into()),
381 )
382 }
383
384 pub fn to_have_text(self, text: impl Into<String>) -> LocatorAssertion {
386 LocatorAssertion::new(self.locator, LocatorAssertionKind::HasText(text.into()))
387 }
388
389 pub fn to_have_count(self, count: usize) -> LocatorAssertion {
391 LocatorAssertion::new(self.locator, LocatorAssertionKind::Count(count))
392 }
393}
394
395#[derive(Debug)]
397#[must_use = "locator assertions do nothing unless awaited"]
398pub struct LocatorAssertion {
399 locator: Locator,
400 kind: LocatorAssertionKind,
401}
402
403#[derive(Debug)]
404enum LocatorAssertionKind {
405 Visible,
406 Hidden,
407 ContainsText(String),
408 HasText(String),
409 Count(usize),
410}
411
412impl LocatorAssertion {
413 fn new(locator: Locator, kind: LocatorAssertionKind) -> Self {
414 Self { locator, kind }
415 }
416
417 pub fn timeout(mut self, timeout: Duration) -> Self {
419 self.locator.timeout = Some(timeout);
420 self
421 }
422
423 async fn run(self) -> Result<PaneSnapshot> {
424 wait_for_assertion(self.locator, self.kind).await
425 }
426}
427
428impl IntoFuture for LocatorAssertion {
429 type Output = Result<PaneSnapshot>;
430 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
431
432 fn into_future(self) -> Self::IntoFuture {
433 Box::pin(self.run())
434 }
435}
436
437async fn wait_for_locator_state(locator: Locator, state: LocatorState) -> Result<PaneSnapshot> {
438 wait_until(
439 locator,
440 move |matches, _snapshot| match state {
441 LocatorState::Visible => !matches.is_empty(),
442 LocatorState::Hidden => matches.is_empty(),
443 },
444 format!("locator to be {state:?}"),
445 )
446 .await
447}
448
449async fn wait_for_assertion(locator: Locator, kind: LocatorAssertionKind) -> Result<PaneSnapshot> {
450 let description = assertion_description(&kind);
451 let timeout = locator
452 .timeout
453 .or_else(|| crate::wait::resolved_wait_timeout(locator.pane.configured_default_timeout()));
454 let deadline = timeout.map(|timeout| Instant::now() + timeout);
455 loop {
456 let snapshot = locator.pane.snapshot().await?;
457 let matches = locator.resolve(&snapshot).await?;
458 match assertion_outcome(&matches, &kind) {
459 AssertionOutcome::Matched => return Ok(snapshot),
460 AssertionOutcome::Continue => {}
461 AssertionOutcome::StrictViolation => {
462 return Err(strict_locator_error(
463 matches.len(),
464 locator.describe(),
465 &snapshot,
466 ));
467 }
468 }
469 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
470 return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
471 description,
472 timeout.expect("deadline implies timeout"),
473 snapshot,
474 )));
475 }
476 sleep_until_next_poll(deadline, locator.poll_interval).await;
477 }
478}
479
480async fn wait_until(
481 locator: Locator,
482 predicate: impl Fn(&[LocatorMatch], &PaneSnapshot) -> bool,
483 description: String,
484) -> Result<PaneSnapshot> {
485 let timeout = locator
486 .timeout
487 .or_else(|| crate::wait::resolved_wait_timeout(locator.pane.configured_default_timeout()));
488 let deadline = timeout.map(|timeout| Instant::now() + timeout);
489 loop {
490 let snapshot = locator.pane.snapshot().await?;
491 let matches = locator.resolve(&snapshot).await?;
492 if predicate(&matches, &snapshot) {
493 return Ok(snapshot);
494 }
495 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
496 return Err(RmuxError::wait_timeout(WaitTimeoutError::new(
497 description,
498 timeout.expect("deadline implies timeout"),
499 snapshot,
500 )));
501 }
502 sleep_until_next_poll(deadline, locator.poll_interval).await;
503 }
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507enum AssertionOutcome {
508 Matched,
509 Continue,
510 StrictViolation,
511}
512
513fn assertion_outcome(matches: &[LocatorMatch], kind: &LocatorAssertionKind) -> AssertionOutcome {
514 match kind {
515 LocatorAssertionKind::Visible => strict_unary_outcome(matches, |_| true),
516 LocatorAssertionKind::Hidden => {
517 if matches.is_empty() {
518 AssertionOutcome::Matched
519 } else {
520 AssertionOutcome::Continue
521 }
522 }
523 LocatorAssertionKind::ContainsText(text) => {
524 strict_unary_outcome(matches, |item| item.text_match.text.contains(text))
525 }
526 LocatorAssertionKind::HasText(text) => {
527 strict_unary_outcome(matches, |item| item.text_match.text == *text)
528 }
529 LocatorAssertionKind::Count(count) => {
530 if matches.len() == *count {
531 AssertionOutcome::Matched
532 } else {
533 AssertionOutcome::Continue
534 }
535 }
536 }
537}
538
539fn strict_unary_outcome(
540 matches: &[LocatorMatch],
541 predicate: impl FnOnce(&LocatorMatch) -> bool,
542) -> AssertionOutcome {
543 match matches {
544 [] => AssertionOutcome::Continue,
545 [item] if predicate(item) => AssertionOutcome::Matched,
546 [_] => AssertionOutcome::Continue,
547 _ => AssertionOutcome::StrictViolation,
548 }
549}
550
551fn assertion_description(kind: &LocatorAssertionKind) -> String {
552 match kind {
553 LocatorAssertionKind::Visible => "locator to be visible".to_owned(),
554 LocatorAssertionKind::Hidden => "locator to be hidden".to_owned(),
555 LocatorAssertionKind::ContainsText(text) => format!("locator to contain text `{text}`"),
556 LocatorAssertionKind::HasText(text) => format!("locator to have text `{text}`"),
557 LocatorAssertionKind::Count(count) => format!("locator to have count {count}"),
558 }
559}
560
561fn strict_locator_error(count: usize, query: String, snapshot: &PaneSnapshot) -> RmuxError {
562 RmuxError::protocol(rmux_proto::RmuxError::Server(format!(
563 "strict locator violation: expected 1 match, found {count}; locator: {query}; last visible screen:\n{}",
564 snapshot.visible_text()
565 )))
566}
567
568async fn sleep_until_next_poll(deadline: Option<Instant>, poll_interval: Duration) {
569 let Some(deadline) = deadline else {
570 tokio::time::sleep(poll_interval).await;
571 return;
572 };
573 let now = Instant::now();
574 if now < deadline {
575 tokio::time::sleep(poll_interval.min(deadline - now)).await;
576 }
577}