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(NAVIGATION_ID_SEQUENCE.fetch_add(1, Ordering::Relaxed))
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
43impl fmt::Display for NavigationId {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(f, "nav#{}", self.0)
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum NavigationCancellationReason {
55 Superseded,
57 Stopped,
59 WebViewDestroyed,
61 Other,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum NavigationEvent {
76 Started {
77 id: NavigationId,
78 requested_url: String,
79 },
80 Succeeded {
81 id: NavigationId,
82 final_url: String,
83 },
84 Failed {
85 id: NavigationId,
86 error: LoadError,
87 },
88 Cancelled {
89 id: NavigationId,
90 reason: NavigationCancellationReason,
91 },
92}
93
94impl NavigationEvent {
95 pub fn id(&self) -> NavigationId {
96 match self {
97 NavigationEvent::Started { id, .. }
98 | NavigationEvent::Succeeded { id, .. }
99 | NavigationEvent::Failed { id, .. }
100 | NavigationEvent::Cancelled { id, .. } => *id,
101 }
102 }
103
104 pub fn is_terminal(&self) -> bool {
105 !matches!(self, NavigationEvent::Started { .. })
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum WebViewStateChange {
115 Location {
116 url: String,
117 },
118 Title {
119 title: Option<String>,
121 },
122 Favicon {
123 png_bytes: Option<Vec<u8>>,
125 },
126 BackForwardAvailability {
127 can_go_back: bool,
128 can_go_forward: bool,
129 },
130}
131
132pub enum WebViewObservedEvent<'a> {
136 Navigation(&'a NavigationEvent),
137 State(&'a WebViewStateChange),
138}
139
140pub type WebViewEventObserver = Arc<dyn Fn(WebViewObservedEvent<'_>) + Send + Sync>;
144
145#[derive(Debug, Default)]
149pub struct NavigationProgress {
150 newest: Option<NavigationId>,
151 newest_terminal: bool,
152}
153
154impl NavigationProgress {
155 pub fn apply(&mut self, event: &NavigationEvent) {
157 match event {
158 NavigationEvent::Started { id, .. } => {
159 self.newest = Some(*id);
160 self.newest_terminal = false;
161 }
162 terminal => {
163 if self.newest == Some(terminal.id()) {
164 self.newest_terminal = true;
165 }
166 }
167 }
168 }
169
170 pub fn is_loading(&self) -> bool {
172 self.newest.is_some() && !self.newest_terminal
173 }
174
175 pub fn current(&self) -> Option<NavigationId> {
177 if self.newest_terminal {
178 None
179 } else {
180 self.newest
181 }
182 }
183
184 pub fn is_current(&self, id: NavigationId) -> bool {
186 self.newest == Some(id)
187 }
188
189 pub fn classify<'a>(&mut self, event: &'a NavigationEvent) -> NavigationOutcome<'a> {
194 self.apply(event);
195 match event {
196 NavigationEvent::Started { requested_url, .. } => {
197 NavigationOutcome::Started { requested_url }
198 }
199 NavigationEvent::Succeeded { id, final_url } if self.is_current(*id) => {
200 NavigationOutcome::Loaded { final_url }
201 }
202 NavigationEvent::Failed { id, error } if self.is_current(*id) => {
203 NavigationOutcome::Failed { error }
204 }
205 _ => NavigationOutcome::Superseded,
208 }
209 }
210}
211
212#[derive(Debug, PartialEq, Eq)]
215pub enum NavigationOutcome<'a> {
216 Started { requested_url: &'a str },
217 Loaded { final_url: &'a str },
218 Failed { error: &'a LoadError },
219 Superseded,
220}
221
222#[derive(Debug, Clone, Default, PartialEq, Eq)]
226pub struct ObservedWebViewState {
227 pub url: Option<String>,
228 pub title: Option<String>,
229 pub favicon_png: Option<Vec<u8>>,
230 pub can_go_back: bool,
231 pub can_go_forward: bool,
232}
233
234impl ObservedWebViewState {
235 pub fn apply(&mut self, change: WebViewStateChange) {
238 match change {
239 WebViewStateChange::Location { url } => self.url = Some(url),
240 WebViewStateChange::Title { title } => self.title = title,
241 WebViewStateChange::Favicon { png_bytes } => self.favicon_png = png_bytes,
242 WebViewStateChange::BackForwardAvailability {
243 can_go_back,
244 can_go_forward,
245 } => {
246 self.can_go_back = can_go_back;
247 self.can_go_forward = can_go_forward;
248 }
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::traits::{LoadError, LoadErrorKind};
257
258 fn id(raw: u64) -> NavigationId {
259 NavigationId(raw)
260 }
261
262 fn started(raw: u64) -> NavigationEvent {
263 NavigationEvent::Started {
264 id: id(raw),
265 requested_url: format!("https://example.com/{raw}"),
266 }
267 }
268
269 fn succeeded(raw: u64) -> NavigationEvent {
270 NavigationEvent::Succeeded {
271 id: id(raw),
272 final_url: format!("https://example.com/{raw}"),
273 }
274 }
275
276 #[test]
277 fn classify_reports_the_current_attempt_only() {
278 let mut progress = NavigationProgress::default();
279 assert_eq!(
280 progress.classify(&started(1)),
281 NavigationOutcome::Started {
282 requested_url: "https://example.com/1",
283 }
284 );
285 assert_eq!(
286 progress.classify(&succeeded(1)),
287 NavigationOutcome::Loaded {
288 final_url: "https://example.com/1",
289 }
290 );
291
292 progress.classify(&started(2));
295 assert_eq!(
296 progress.classify(&succeeded(1)),
297 NavigationOutcome::Superseded
298 );
299 }
300
301 #[test]
302 fn classify_treats_cancellation_as_control_flow() {
303 let mut progress = NavigationProgress::default();
304 progress.classify(&started(1));
305 assert_eq!(
306 progress.classify(&NavigationEvent::Cancelled {
307 id: id(1),
308 reason: NavigationCancellationReason::Superseded,
309 }),
310 NavigationOutcome::Superseded
311 );
312 }
313
314 #[test]
315 fn navigation_id_displays_for_diagnostics() {
316 assert_eq!(id(42).to_string(), "nav#42");
317 }
318
319 #[test]
320 fn progress_tracks_single_attempt() {
321 let mut progress = NavigationProgress::default();
322 assert!(!progress.is_loading());
323 progress.apply(&started(1));
324 assert!(progress.is_loading());
325 assert_eq!(progress.current(), Some(id(1)));
326 progress.apply(&succeeded(1));
327 assert!(!progress.is_loading());
328 assert_eq!(progress.current(), None);
329 assert!(progress.is_current(id(1)));
330 }
331
332 #[test]
333 fn terminal_for_older_attempt_keeps_newest_loading() {
334 let mut progress = NavigationProgress::default();
335 progress.apply(&started(1));
336 progress.apply(&started(2));
337 progress.apply(&NavigationEvent::Cancelled {
338 id: id(1),
339 reason: NavigationCancellationReason::Superseded,
340 });
341 assert!(progress.is_loading());
342 assert_eq!(progress.current(), Some(id(2)));
343 assert!(!progress.is_current(id(1)));
344 }
345
346 #[test]
347 fn failed_terminal_ends_loading_for_current_attempt() {
348 let mut progress = NavigationProgress::default();
349 progress.apply(&started(1));
350 progress.apply(&NavigationEvent::Failed {
351 id: id(1),
352 error: LoadError {
353 failing_url: Some("https://example.com/1".into()),
354 kind: LoadErrorKind::Network,
355 description: "boom".into(),
356 },
357 });
358 assert!(!progress.is_loading());
359 }
360
361 #[test]
362 fn observed_state_applies_none_clears() {
363 let mut state = ObservedWebViewState::default();
364 state.apply(WebViewStateChange::Title {
365 title: Some("Example".into()),
366 });
367 state.apply(WebViewStateChange::Favicon {
368 png_bytes: Some(vec![1, 2, 3]),
369 });
370 state.apply(WebViewStateChange::Location {
371 url: "https://example.com/".into(),
372 });
373 state.apply(WebViewStateChange::BackForwardAvailability {
374 can_go_back: true,
375 can_go_forward: false,
376 });
377 assert_eq!(state.title.as_deref(), Some("Example"));
378 assert_eq!(state.favicon_png.as_deref(), Some(&[1u8, 2, 3][..]));
379 assert!(state.can_go_back);
380
381 state.apply(WebViewStateChange::Title { title: None });
382 state.apply(WebViewStateChange::Favicon { png_bytes: None });
383 assert_eq!(state.title, None);
384 assert_eq!(state.favicon_png, None);
385 assert_eq!(state.url.as_deref(), Some("https://example.com/"));
386 }
387}