Skip to main content

thread_engine/
web.rs

1//! In-world web reader — the **old web, inside the Thread**.
2//!
3//! Signboards and links on the Thread point at ordinary `https://` pages. Rather
4//! than kick you out to an OS browser, this fetches the page on a background
5//! thread, renders a **reader view** (title + readable text + followable links)
6//! in an in-world panel, and lets you walk the 2D web without ever leaving the
7//! Thread browser. It's a reader, not a full engine — no JS/CSS layout — but it
8//! keeps you in-world and is dependency-free, robust, and testable. (A full
9//! embedded page surface via an offscreen browser engine is the later upgrade.)
10
11use std::sync::mpsc::{channel, Receiver, TryRecvError};
12
13/// A reader-view rendering of a page.
14#[derive(Debug, Clone, Default)]
15pub struct ReaderDoc {
16    pub title: String,
17    pub text: String,
18    /// `(display text, absolute url)` for each followable link.
19    pub links: Vec<(String, String)>,
20}
21
22enum State {
23    Idle,
24    Loading(String),
25    Loaded(Box<ReaderDoc>),
26    Error(String, String),
27}
28
29/// The in-world web reader (one page at a time, with back-history).
30pub struct WebReader {
31    state: State,
32    url: Option<String>,
33    history: Vec<String>,
34    pending: Option<(String, Receiver<Result<ReaderDoc, String>>)>,
35}
36
37impl Default for WebReader {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl WebReader {
44    pub fn new() -> Self {
45        Self {
46            state: State::Idle,
47            url: None,
48            history: Vec::new(),
49            pending: None,
50        }
51    }
52
53    pub fn is_open(&self) -> bool {
54        !matches!(self.state, State::Idle)
55    }
56    pub fn can_go_back(&self) -> bool {
57        self.history.len() > 1
58    }
59
60    pub fn close(&mut self) {
61        self.state = State::Idle;
62        self.url = None;
63        self.history.clear();
64        self.pending = None;
65    }
66
67    /// Open a URL (pushed onto history).
68    pub fn open(&mut self, url: &str) {
69        self.history.push(url.to_string());
70        self.load(url.to_string());
71    }
72
73    /// Go back to the previous page.
74    pub fn back(&mut self) {
75        if self.history.len() > 1 {
76            self.history.pop();
77            if let Some(prev) = self.history.last().cloned() {
78                self.load(prev);
79            }
80        }
81    }
82
83    fn load(&mut self, url: String) {
84        let (tx, rx) = channel();
85        let fetch_url = url.clone();
86        std::thread::spawn(move || {
87            let _ = tx.send(fetch_reader(&fetch_url));
88        });
89        self.state = State::Loading(url.clone());
90        self.url = Some(url.clone());
91        self.pending = Some((url, rx));
92    }
93
94    /// Collect a finished fetch, if any. Call once per frame.
95    pub fn poll(&mut self) {
96        let Some((url, rx)) = &self.pending else {
97            return;
98        };
99        match rx.try_recv() {
100            Ok(Ok(doc)) => {
101                self.state = State::Loaded(Box::new(doc));
102                self.pending = None;
103            }
104            Ok(Err(e)) => {
105                self.state = State::Error(url.clone(), e);
106                self.pending = None;
107            }
108            Err(TryRecvError::Empty) => {}
109            Err(TryRecvError::Disconnected) => {
110                let u = url.clone();
111                self.state = State::Error(u, "fetch stopped".into());
112                self.pending = None;
113            }
114        }
115    }
116
117    /// What to draw this frame (owned, so egui closures stay free).
118    pub fn view(&self) -> Option<WebView> {
119        match &self.state {
120            State::Idle => None,
121            State::Loading(url) => Some(WebView {
122                url: url.clone(),
123                title: String::new(),
124                text: String::new(),
125                links: Vec::new(),
126                loading: true,
127                error: None,
128            }),
129            State::Loaded(d) => Some(WebView {
130                url: self.url.clone().unwrap_or_default(),
131                title: if d.title.is_empty() {
132                    self.url.clone().unwrap_or_default()
133                } else {
134                    d.title.clone()
135                },
136                text: d.text.clone(),
137                links: d.links.clone(),
138                loading: false,
139                error: None,
140            }),
141            State::Error(url, msg) => Some(WebView {
142                url: url.clone(),
143                title: url.clone(),
144                text: String::new(),
145                links: Vec::new(),
146                loading: false,
147                error: Some(msg.clone()),
148            }),
149        }
150    }
151}
152
153/// An owned snapshot for rendering.
154#[derive(Clone)]
155pub struct WebView {
156    pub url: String,
157    pub title: String,
158    pub text: String,
159    pub links: Vec<(String, String)>,
160    pub loading: bool,
161    pub error: Option<String>,
162}
163
164/// Fetch a URL and render it to a [`ReaderDoc`] (blocking; run on a bg thread).
165fn fetch_reader(url: &str) -> Result<ReaderDoc, String> {
166    let rt = tokio::runtime::Builder::new_current_thread()
167        .enable_all()
168        .build()
169        .map_err(|e| e.to_string())?;
170    rt.block_on(async {
171        let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
172        let status = resp.status();
173        let final_url = resp.url().to_string();
174        let body = resp.text().await.map_err(|e| e.to_string())?;
175        if !status.is_success() {
176            return Err(format!("HTTP {}", status.as_u16()));
177        }
178        Ok(read_html(&body, &final_url))
179    })
180}
181
182// ---------------------------------------------------------------------------
183// Reader-view extraction (dependency-free HTML → title + text + links).
184// ---------------------------------------------------------------------------
185
186/// Turn raw HTML into a reader view: title, readable text, absolute links.
187pub fn read_html(html: &str, base_url: &str) -> ReaderDoc {
188    let cleaned = remove_blocks(html);
189    let title = extract_tag_text(&cleaned, "title").unwrap_or_default();
190    let links = extract_links(&cleaned, base_url);
191    let text = to_text(&cleaned);
192    ReaderDoc {
193        title: decode_entities(&title).trim().to_string(),
194        text,
195        links,
196    }
197}
198
199/// Strip `<script>`, `<style>` and comments (with their contents).
200fn remove_blocks(html: &str) -> String {
201    let mut out = String::with_capacity(html.len());
202    let lower = html.to_ascii_lowercase();
203    let bytes = html.as_bytes();
204    let mut i = 0;
205    while i < bytes.len() {
206        let rest = &lower[i..];
207        if let Some(end) = block_end(rest, "<script", "</script>")
208            .or_else(|| block_end(rest, "<style", "</style>"))
209            .or_else(|| comment_end(rest))
210        {
211            i += end;
212        } else {
213            out.push(html[i..].chars().next().unwrap_or(' '));
214            i += html[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
215        }
216    }
217    out
218}
219
220fn block_end(rest: &str, open: &str, close: &str) -> Option<usize> {
221    if rest.starts_with(open) {
222        return Some(
223            rest.find(close)
224                .map(|e| e + close.len())
225                .unwrap_or(rest.len()),
226        );
227    }
228    None
229}
230
231fn comment_end(rest: &str) -> Option<usize> {
232    if rest.starts_with("<!--") {
233        return Some(rest.find("-->").map(|e| e + 3).unwrap_or(rest.len()));
234    }
235    None
236}
237
238/// Text inside the first `<tag>…</tag>` (case-insensitive).
239fn extract_tag_text(html: &str, tag: &str) -> Option<String> {
240    let lower = html.to_ascii_lowercase();
241    let open = format!("<{tag}");
242    let start = lower.find(&open)?;
243    let gt = lower[start..].find('>')? + start + 1;
244    let end = lower[gt..].find(&format!("</{tag}"))? + gt;
245    Some(html[gt..end].to_string())
246}
247
248/// Pull `<a href>` links, resolving each to an absolute URL against `base`.
249fn extract_links(html: &str, base: &str) -> Vec<(String, String)> {
250    let base_url = reqwest::Url::parse(base).ok();
251    let lower = html.to_ascii_lowercase();
252    let mut links = Vec::new();
253    let mut search = 0;
254    while let Some(rel) = lower[search..].find("<a ") {
255        let at = search + rel;
256        let Some(gt) = lower[at..].find('>') else {
257            break;
258        };
259        let tag_end = at + gt;
260        let tag = &html[at..tag_end];
261        let href = attr_value(tag, "href");
262        // Inner text up to </a>.
263        let inner_end = lower[tag_end..]
264            .find("</a")
265            .map(|e| tag_end + e)
266            .unwrap_or(tag_end);
267        let text = decode_entities(&to_text(&html[tag_end + 1..inner_end]))
268            .trim()
269            .to_string();
270        search = inner_end + 3;
271        if let Some(href) = href {
272            if href.starts_with('#')
273                || href.starts_with("javascript:")
274                || href.starts_with("mailto:")
275            {
276                continue;
277            }
278            let abs = base_url
279                .as_ref()
280                .and_then(|b| b.join(&href).ok())
281                .map(|u| u.to_string())
282                .unwrap_or(href);
283            if abs.starts_with("http") && !text.is_empty() {
284                links.push((text, abs));
285            }
286        }
287    }
288    links.truncate(80);
289    links
290}
291
292/// Read a quoted attribute value out of a start tag.
293fn attr_value(tag: &str, name: &str) -> Option<String> {
294    let lower = tag.to_ascii_lowercase();
295    let key = format!("{name}=");
296    let idx = lower.find(&key)? + key.len();
297    let rest = &tag[idx..];
298    let quote = rest.chars().next()?;
299    if quote == '"' || quote == '\'' {
300        let end = rest[1..].find(quote)? + 1;
301        Some(rest[1..end].to_string())
302    } else {
303        let end = rest
304            .find(|c: char| c.is_whitespace() || c == '>')
305            .unwrap_or(rest.len());
306        Some(rest[..end].to_string())
307    }
308}
309
310/// Strip tags to readable text: block tags become line breaks, whitespace
311/// collapses, entities decode.
312fn to_text(html: &str) -> String {
313    let mut out = String::with_capacity(html.len());
314    let bytes = html.as_bytes();
315    let mut i = 0;
316    while i < bytes.len() {
317        if bytes[i] == b'<' {
318            let end = html[i..]
319                .find('>')
320                .map(|e| i + e + 1)
321                .unwrap_or(bytes.len());
322            let tag = html[i..end].to_ascii_lowercase();
323            if is_break_tag(&tag) {
324                out.push('\n');
325            } else {
326                out.push(' ');
327            }
328            i = end;
329        } else {
330            out.push(html[i..].chars().next().unwrap_or(' '));
331            i += html[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
332        }
333    }
334    collapse_ws(&decode_entities(&out))
335}
336
337fn is_break_tag(tag: &str) -> bool {
338    for t in [
339        "</p",
340        "<br",
341        "</div",
342        "</h1",
343        "</h2",
344        "</h3",
345        "</h4",
346        "</li",
347        "</tr",
348        "</section",
349        "</article",
350        "</header",
351        "</footer",
352    ] {
353        if tag.starts_with(t) {
354            return true;
355        }
356    }
357    false
358}
359
360/// Collapse runs of spaces, and runs of blank lines to at most one.
361fn collapse_ws(s: &str) -> String {
362    let mut out = String::with_capacity(s.len());
363    let mut blank_run = 0;
364    for line in s.lines() {
365        let mut trimmed = String::with_capacity(line.len());
366        let mut prev_space = false;
367        for c in line.chars() {
368            if c.is_whitespace() {
369                if !prev_space {
370                    trimmed.push(' ');
371                }
372                prev_space = true;
373            } else {
374                trimmed.push(c);
375                prev_space = false;
376            }
377        }
378        let t = trimmed.trim();
379        if t.is_empty() {
380            blank_run += 1;
381            if blank_run <= 1 && !out.is_empty() {
382                out.push('\n');
383            }
384        } else {
385            blank_run = 0;
386            out.push_str(t);
387            out.push('\n');
388        }
389    }
390    out.trim().to_string()
391}
392
393/// Decode the common HTML entities (named + numeric).
394fn decode_entities(s: &str) -> String {
395    let mut out = String::with_capacity(s.len());
396    let mut chars = s.char_indices().peekable();
397    while let Some((i, c)) = chars.next() {
398        if c != '&' {
399            out.push(c);
400            continue;
401        }
402        let rest = &s[i..];
403        let Some(semi) = rest[..rest.len().min(12)].find(';') else {
404            out.push('&');
405            continue;
406        };
407        let ent = &rest[1..semi];
408        let decoded = match ent {
409            "amp" => Some('&'),
410            "lt" => Some('<'),
411            "gt" => Some('>'),
412            "quot" => Some('"'),
413            "apos" | "#39" => Some('\''),
414            "nbsp" => Some(' '),
415            "mdash" | "#8212" => Some('—'),
416            "ndash" | "#8211" => Some('–'),
417            "hellip" | "#8230" => Some('…'),
418            "rsquo" | "#8217" => Some('\u{2019}'),
419            "lsquo" | "#8216" => Some('\u{2018}'),
420            "ldquo" | "#8220" => Some('\u{201C}'),
421            "rdquo" | "#8221" => Some('\u{201D}'),
422            _ => ent
423                .strip_prefix('#')
424                .and_then(|n| n.parse::<u32>().ok())
425                .and_then(char::from_u32),
426        };
427        if let Some(d) = decoded {
428            out.push(d);
429            for _ in 0..(semi) {
430                chars.next();
431            }
432        } else {
433            out.push('&');
434        }
435    }
436    out
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn reads_title_text_and_absolute_links() {
445        let html = r##"
446          <html><head><title>Pixiel &amp; Co</title>
447          <style>body{color:red}</style></head>
448          <body>
449            <script>var x = '<not text>';</script>
450            <h1>The Forge</h1>
451            <p>Where prompts are hammered into worlds.</p>
452            <a href="/about">About us</a>
453            <a href="https://pixygon.io/thread">The Thread</a>
454            <a href="#top">skip</a>
455          </body></html>
456        "##;
457        let doc = read_html(html, "https://pixiel.ai/forge");
458        assert_eq!(doc.title, "Pixiel & Co");
459        assert!(doc.text.contains("The Forge"));
460        assert!(doc.text.contains("hammered into worlds"));
461        assert!(!doc.text.contains("var x"), "script contents stripped");
462        assert!(!doc.text.contains("color:red"), "style contents stripped");
463        // Relative link resolved to absolute; fragment link dropped.
464        assert!(doc
465            .links
466            .iter()
467            .any(|(t, u)| t == "About us" && u == "https://pixiel.ai/about"));
468        assert!(doc
469            .links
470            .iter()
471            .any(|(_, u)| u == "https://pixygon.io/thread"));
472        assert!(!doc.links.iter().any(|(t, _)| t == "skip"));
473    }
474
475    #[test]
476    fn decodes_entities() {
477        assert_eq!(
478            decode_entities("a &amp; b &lt;c&gt; &#39;d&#39; &mdash; e"),
479            "a & b <c> 'd' — e"
480        );
481    }
482}