1pub(crate) mod normalizer;
6
7pub use normalizer::add_observer;
11
12use crate::traits::LoadError;
13use std::fmt;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub struct NavigationId(u64);
23
24static NAVIGATION_ID_SEQUENCE: AtomicU64 = AtomicU64::new(1);
25
26impl NavigationId {
27 pub(crate) fn next() -> Self {
29 Self(next_navigation_id(&NAVIGATION_ID_SEQUENCE))
30 }
31
32 pub fn get(self) -> u64 {
33 self.0
34 }
35
36 #[cfg(feature = "test-support")]
38 pub fn from_raw(raw: u64) -> Self {
39 Self(raw)
40 }
41}
42
43fn next_navigation_id(sequence: &AtomicU64) -> u64 {
44 sequence
45 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
46 current.checked_add(1)
47 })
48 .expect("navigation identity space exhausted")
49}
50
51impl fmt::Display for NavigationId {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(f, "nav#{}", self.0)
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum NavigationCancellationReason {
63 Superseded,
65 Stopped,
67 WebViewDestroyed,
69 Other,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum NavigationEvent {
84 Started {
85 id: NavigationId,
86 requested_url: String,
87 },
88 Succeeded {
89 id: NavigationId,
90 final_url: String,
91 },
92 Failed {
93 id: NavigationId,
94 error: LoadError,
95 },
96 Cancelled {
97 id: NavigationId,
98 reason: NavigationCancellationReason,
99 },
100}
101
102impl NavigationEvent {
103 pub fn id(&self) -> NavigationId {
104 match self {
105 NavigationEvent::Started { id, .. }
106 | NavigationEvent::Succeeded { id, .. }
107 | NavigationEvent::Failed { id, .. }
108 | NavigationEvent::Cancelled { id, .. } => *id,
109 }
110 }
111
112 pub fn is_terminal(&self) -> bool {
113 !matches!(self, NavigationEvent::Started { .. })
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum WebViewStateChange {
123 Location {
124 url: String,
125 },
126 Title {
127 title: Option<String>,
129 },
130 Favicon {
131 png_bytes: Option<Vec<u8>>,
133 },
134 BackForwardAvailability {
135 can_go_back: bool,
136 can_go_forward: bool,
137 },
138}
139
140pub enum WebViewObservedEvent<'a> {
144 Navigation(&'a NavigationEvent),
145 State(&'a WebViewStateChange),
146}
147
148pub type WebViewEventObserver = Arc<dyn Fn(WebViewObservedEvent<'_>) + Send + Sync>;
152
153#[derive(Debug, Default)]
157pub struct NavigationProgress {
158 newest: Option<NavigationId>,
159 newest_terminal: bool,
160}
161
162impl NavigationProgress {
163 pub fn apply(&mut self, event: &NavigationEvent) {
165 match event {
166 NavigationEvent::Started { id, .. } => {
167 self.newest = Some(*id);
168 self.newest_terminal = false;
169 }
170 terminal => {
171 if self.newest == Some(terminal.id()) {
172 self.newest_terminal = true;
173 }
174 }
175 }
176 }
177
178 pub fn is_loading(&self) -> bool {
180 self.newest.is_some() && !self.newest_terminal
181 }
182
183 pub fn current(&self) -> Option<NavigationId> {
185 if self.newest_terminal {
186 None
187 } else {
188 self.newest
189 }
190 }
191
192 pub fn is_current(&self, id: NavigationId) -> bool {
194 self.newest == Some(id)
195 }
196
197 pub fn classify<'a>(&mut self, event: &'a NavigationEvent) -> NavigationOutcome<'a> {
202 self.apply(event);
203 match event {
204 NavigationEvent::Started { requested_url, .. } => {
205 NavigationOutcome::Started { requested_url }
206 }
207 NavigationEvent::Succeeded { id, final_url } if self.is_current(*id) => {
208 NavigationOutcome::Loaded { final_url }
209 }
210 NavigationEvent::Failed { id, error } if self.is_current(*id) => {
211 NavigationOutcome::Failed { error }
212 }
213 _ => NavigationOutcome::Superseded,
216 }
217 }
218}
219
220#[derive(Debug, PartialEq, Eq)]
223pub enum NavigationOutcome<'a> {
224 Started { requested_url: &'a str },
225 Loaded { final_url: &'a str },
226 Failed { error: &'a LoadError },
227 Superseded,
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq)]
234pub struct ObservedWebViewState {
235 pub url: Option<String>,
236 pub title: Option<String>,
237 pub favicon_png: Option<Vec<u8>>,
238 pub can_go_back: bool,
239 pub can_go_forward: bool,
240}
241
242impl ObservedWebViewState {
243 pub fn apply(&mut self, change: WebViewStateChange) {
246 match change {
247 WebViewStateChange::Location { url } => self.url = Some(url),
248 WebViewStateChange::Title { title } => self.title = title,
249 WebViewStateChange::Favicon { png_bytes } => self.favicon_png = png_bytes,
250 WebViewStateChange::BackForwardAvailability {
251 can_go_back,
252 can_go_forward,
253 } => {
254 self.can_go_back = can_go_back;
255 self.can_go_forward = can_go_forward;
256 }
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::traits::{LoadError, LoadErrorKind};
265
266 #[test]
267 fn navigation_ids_exhaust_instead_of_wrapping() {
268 let sequence = AtomicU64::new(u64::MAX - 1);
269 assert_eq!(next_navigation_id(&sequence), u64::MAX - 1);
270 assert_eq!(sequence.load(Ordering::Relaxed), u64::MAX);
271 assert!(
272 std::panic::catch_unwind(|| next_navigation_id(&sequence)).is_err(),
273 "an exhausted process-wide identity must never wrap"
274 );
275 assert_eq!(sequence.load(Ordering::Relaxed), u64::MAX);
276 }
277
278 fn id(raw: u64) -> NavigationId {
279 NavigationId(raw)
280 }
281
282 fn started(raw: u64) -> NavigationEvent {
283 NavigationEvent::Started {
284 id: id(raw),
285 requested_url: format!("https://example.com/{raw}"),
286 }
287 }
288
289 fn succeeded(raw: u64) -> NavigationEvent {
290 NavigationEvent::Succeeded {
291 id: id(raw),
292 final_url: format!("https://example.com/{raw}"),
293 }
294 }
295
296 #[test]
297 fn classify_reports_the_current_attempt_only() {
298 let mut progress = NavigationProgress::default();
299 assert_eq!(
300 progress.classify(&started(1)),
301 NavigationOutcome::Started {
302 requested_url: "https://example.com/1",
303 }
304 );
305 assert_eq!(
306 progress.classify(&succeeded(1)),
307 NavigationOutcome::Loaded {
308 final_url: "https://example.com/1",
309 }
310 );
311
312 progress.classify(&started(2));
315 assert_eq!(
316 progress.classify(&succeeded(1)),
317 NavigationOutcome::Superseded
318 );
319 }
320
321 #[test]
322 fn classify_treats_cancellation_as_control_flow() {
323 let mut progress = NavigationProgress::default();
324 progress.classify(&started(1));
325 assert_eq!(
326 progress.classify(&NavigationEvent::Cancelled {
327 id: id(1),
328 reason: NavigationCancellationReason::Superseded,
329 }),
330 NavigationOutcome::Superseded
331 );
332 }
333
334 #[test]
335 fn navigation_id_displays_for_diagnostics() {
336 assert_eq!(id(42).to_string(), "nav#42");
337 }
338
339 #[test]
340 fn progress_tracks_single_attempt() {
341 let mut progress = NavigationProgress::default();
342 assert!(!progress.is_loading());
343 progress.apply(&started(1));
344 assert!(progress.is_loading());
345 assert_eq!(progress.current(), Some(id(1)));
346 progress.apply(&succeeded(1));
347 assert!(!progress.is_loading());
348 assert_eq!(progress.current(), None);
349 assert!(progress.is_current(id(1)));
350 }
351
352 #[test]
353 fn terminal_for_older_attempt_keeps_newest_loading() {
354 let mut progress = NavigationProgress::default();
355 progress.apply(&started(1));
356 progress.apply(&started(2));
357 progress.apply(&NavigationEvent::Cancelled {
358 id: id(1),
359 reason: NavigationCancellationReason::Superseded,
360 });
361 assert!(progress.is_loading());
362 assert_eq!(progress.current(), Some(id(2)));
363 assert!(!progress.is_current(id(1)));
364 }
365
366 #[test]
367 fn failed_terminal_ends_loading_for_current_attempt() {
368 let mut progress = NavigationProgress::default();
369 progress.apply(&started(1));
370 progress.apply(&NavigationEvent::Failed {
371 id: id(1),
372 error: LoadError {
373 failing_url: Some("https://example.com/1".into()),
374 kind: LoadErrorKind::Network,
375 description: "boom".into(),
376 },
377 });
378 assert!(!progress.is_loading());
379 }
380
381 #[test]
382 fn observed_state_applies_none_clears() {
383 let mut state = ObservedWebViewState::default();
384 state.apply(WebViewStateChange::Title {
385 title: Some("Example".into()),
386 });
387 state.apply(WebViewStateChange::Favicon {
388 png_bytes: Some(vec![1, 2, 3]),
389 });
390 state.apply(WebViewStateChange::Location {
391 url: "https://example.com/".into(),
392 });
393 state.apply(WebViewStateChange::BackForwardAvailability {
394 can_go_back: true,
395 can_go_forward: false,
396 });
397 assert_eq!(state.title.as_deref(), Some("Example"));
398 assert_eq!(state.favicon_png.as_deref(), Some(&[1u8, 2, 3][..]));
399 assert!(state.can_go_back);
400
401 state.apply(WebViewStateChange::Title { title: None });
402 state.apply(WebViewStateChange::Favicon { png_bytes: None });
403 assert_eq!(state.title, None);
404 assert_eq!(state.favicon_png, None);
405 assert_eq!(state.url.as_deref(), Some("https://example.com/"));
406 }
407}