Skip to main content

rich_embed/
web.rs

1//! The web view: a web page in a pane, over a [`WebEngine`].
2//!
3//! The view keeps the page's state in signals (its address, title, whether
4//! it is loading, and whether it can go back or forward), draws a small
5//! address bar over the page, forwards keys and the mouse, and tells the
6//! engine the page's size. Engines hand back frames of cells (a text
7//! browser) or of pixels (a graphical one); pixels are drawn as coloured
8//! half blocks.
9
10use std::cell::RefCell;
11use std::io;
12use std::rc::{Rc, Weak};
13
14use rich::{Segment, Style};
15use rich_intuituive::interact::{Button, Key, KeyCode, Mouse, MouseKind};
16use rich_intuituive::widget::{Canvas, DrawCx, EventCx, Used, Widget, WidgetEvent, widget};
17use rich_intuituive::{Ctx, Node, Signal, signal, watch};
18
19use crate::host::Notify;
20use crate::pixels::{Pixels, half_blocks};
21use crate::program::ProgramEngine;
22use crate::wake::{Fed, connect};
23
24/// Input for the page, in the page's own cells.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum WebInput {
27    Key(Key),
28    /// The mouse, at a cell of the page (the address bar is not counted).
29    Mouse(Mouse),
30    Paste(String),
31}
32
33/// What an engine draws.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum WebFrame {
36    /// Text: one line of rich segments per row, and the text cursor.
37    Cells {
38        lines: Vec<Vec<Segment>>,
39        cursor: Option<(u16, u16)>,
40    },
41    /// An image of the page, scaled into the pane.
42    Pixels(Pixels),
43}
44
45/// Where the page is and what it is doing.
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct PageState {
48    pub url: String,
49    pub title: String,
50    pub loading: bool,
51    pub can_go_back: bool,
52    pub can_go_forward: bool,
53    /// Why the page could not be shown, if it could not.
54    pub error: Option<String>,
55}
56
57/// A browser behind a [`web_view`]: opens pages, takes input, and hands
58/// back frames. Every method is called on the app's thread; work that takes
59/// time happens on the engine's own threads, which call the [`Notify`]
60/// when there is a new frame or a new state.
61///
62/// The view calls [`resize`](Self::resize) before the first
63/// [`open`](Self::open), and again whenever the page's area changes.
64pub trait WebEngine {
65    /// Go to `url`.
66    fn open(&mut self, url: &str) -> io::Result<()>;
67    /// The page's area is now `columns` x `rows` cells.
68    fn resize(&mut self, columns: u16, rows: u16) -> io::Result<()>;
69    fn input(&mut self, input: WebInput) -> io::Result<()>;
70    fn back(&mut self) -> io::Result<()>;
71    fn forward(&mut self) -> io::Result<()>;
72    fn reload(&mut self) -> io::Result<()>;
73    /// The newest frame since the last call, if there is one.
74    fn poll(&mut self) -> Option<WebFrame>;
75    /// Where the page is and what it is doing now.
76    fn state(&self) -> PageState;
77    /// Call `notify` when there is a new frame or state. Given once.
78    fn set_notify(&mut self, notify: Notify);
79}
80
81/// The page's state as signals, and what the app can ask of it. `Copy`,
82/// like a signal: move it into handlers.
83pub struct WebHandle {
84    address: Signal<String>,
85    title: Signal<String>,
86    loading: Signal<bool>,
87    can_go_back: Signal<bool>,
88    can_go_forward: Signal<bool>,
89    error: Signal<Option<String>>,
90    view: Signal<Weak<RefCell<ViewState>>>,
91}
92
93impl Clone for WebHandle {
94    fn clone(&self) -> Self {
95        *self
96    }
97}
98
99impl Copy for WebHandle {}
100
101impl WebHandle {
102    /// The page's address. Setting it opens that address.
103    pub fn address(&self) -> Signal<String> {
104        self.address
105    }
106
107    pub fn title(&self) -> Signal<String> {
108        self.title
109    }
110
111    pub fn loading(&self) -> Signal<bool> {
112        self.loading
113    }
114
115    pub fn can_go_back(&self) -> Signal<bool> {
116        self.can_go_back
117    }
118
119    pub fn can_go_forward(&self) -> Signal<bool> {
120        self.can_go_forward
121    }
122
123    /// Why the page could not be shown, if it could not.
124    pub fn error(&self) -> Signal<Option<String>> {
125        self.error
126    }
127
128    fn with_view(&self, f: impl FnOnce(&mut ViewState)) {
129        if let Some(view) = self.view.with_untracked(Weak::upgrade) {
130            let mut view = view.borrow_mut();
131            f(&mut view);
132            view.sync();
133        }
134    }
135
136    /// Open `url`.
137    pub fn open(&self, url: impl Into<String>) {
138        self.address.set(url.into());
139    }
140
141    pub fn back(&self) {
142        self.with_view(|view| view.act(|engine| engine.back()));
143    }
144
145    pub fn forward(&self) {
146        self.with_view(|view| view.act(|engine| engine.forward()));
147    }
148
149    pub fn reload(&self) {
150        self.with_view(|view| view.act(|engine| engine.reload()));
151    }
152}
153
154struct ViewState {
155    engine: Box<dyn WebEngine>,
156    handle: Option<WebHandle>,
157    /// Bumped when there is something new to draw.
158    wake: Signal<u64>,
159    frame: Option<WebFrame>,
160    /// The page's area it was last given; `None` before the first.
161    size: Option<(u16, u16)>,
162    /// The address to open once the page has a size.
163    first: Option<String>,
164    /// The address the engine was last asked for, or reported.
165    current: String,
166    /// The last call to the engine that failed, and why.
167    failure: Option<String>,
168    /// The engine's wake-up, kept to bring a failure to the signals.
169    notify: Option<Notify>,
170}
171
172impl ViewState {
173    /// Ask the engine for something; a failure is kept for the next
174    /// [`sync`](Self::sync) (this may run while the view draws, which
175    /// writes no signals).
176    fn act(&mut self, f: impl FnOnce(&mut dyn WebEngine) -> io::Result<()>) {
177        if self.size.is_none() {
178            return;
179        }
180        self.failure = f(self.engine.as_mut()).err().map(|e| e.to_string());
181        // An engine that failed at once may never call `notify`; call it
182        // here, so the next turn copies the failure into the signals (this
183        // may run while the view draws, where signals are not written).
184        if self.failure.is_some()
185            && let Some(notify) = &self.notify
186        {
187            notify();
188        }
189    }
190
191    /// Copy the engine's state into the signals.
192    fn sync(&mut self) {
193        let Some(handle) = self.handle else {
194            return;
195        };
196        let state = self.engine.state();
197        if !state.url.is_empty() {
198            self.current = state.url.clone();
199            handle.address.set(state.url);
200        }
201        handle.title.set(state.title);
202        handle.loading.set(state.loading);
203        handle.can_go_back.set(state.can_go_back);
204        handle.can_go_forward.set(state.can_go_forward);
205        handle
206            .error
207            .set(state.error.or_else(|| self.failure.clone()));
208    }
209
210    /// The page's area changed to `columns` x `rows`: tell the engine, and
211    /// open the first page once there is one.
212    fn fit(&mut self, columns: u16, rows: u16) {
213        if columns == 0 || rows == 0 || self.size == Some((columns, rows)) {
214            return;
215        }
216        self.size = Some((columns, rows));
217        self.act(|engine| engine.resize(columns, rows));
218        if let Some(url) = self.first.take() {
219            self.current = url.clone();
220            self.act(|engine| engine.open(&url));
221        }
222    }
223}
224
225impl Fed for ViewState {
226    fn install(&mut self, notify: Notify) {
227        self.notify = Some(notify.clone());
228        self.engine.set_notify(notify);
229    }
230
231    fn pump(this: &Rc<RefCell<Self>>, _cx: &mut Ctx) {
232        let mut view = this.borrow_mut();
233        if let Some(frame) = view.engine.poll() {
234            view.frame = Some(frame);
235            view.wake.update(|n| *n = n.wrapping_add(1));
236        }
237        view.sync();
238    }
239}
240
241/// A web view being built: [`web_view`] or [`web_view_with`], then
242/// [`node`](Self::node) to place it; [`handle`](Self::handle) for its
243/// signals and navigation.
244///
245/// The top row is an address bar: `←` back, `→` forward, `↻` reload, the
246/// address, and `…` while the page loads. Click the address or press
247/// Ctrl+L to type one (Enter opens it, Esc gives up); Alt+Left, Alt+Right
248/// and F5 go back, forward and reload. Every other key, the mouse and
249/// pastes go to the page while the view has the focus, except the keys
250/// [`release_keys`](Self::release_keys) names.
251pub struct WebView {
252    state: Rc<RefCell<ViewState>>,
253    handle: WebHandle,
254    bar: bool,
255    release: Vec<Key>,
256}
257
258/// A web page at `url`, shown by a terminal browser named by the
259/// environment or found on `PATH` ([`ProgramEngine::detect`]). Name one
260/// with [`web_view_with`] and [`ProgramEngine::new`].
261///
262/// # Panics
263/// Outside a running or building app, like [`signal`].
264pub fn web_view(url: impl Into<String>) -> WebView {
265    web_view_with(ProgramEngine::detect(), url)
266}
267
268/// A web page at `url`, shown by `engine`.
269///
270/// ```no_run
271/// # extern crate rich_intuituive as intuituive;
272/// use intuituive::prelude::*;
273/// use rich_embed::{web_view_with, ProgramEngine};
274///
275/// App::new(|| {
276///     let page = web_view_with(ProgramEngine::new("w3m"), "https://example.com");
277///     let address = page.handle().address();
278///     column([page.node(), text!("{address}").fixed(1)])
279/// })
280/// .run()
281/// # ; Ok::<(), std::io::Error>(())
282/// ```
283///
284/// # Panics
285/// Outside a running or building app, like [`signal`].
286pub fn web_view_with(engine: impl WebEngine + 'static, url: impl Into<String>) -> WebView {
287    let url = url.into();
288    let state = Rc::new(RefCell::new(ViewState {
289        engine: Box::new(engine),
290        handle: None,
291        wake: signal(0),
292        frame: None,
293        size: None,
294        first: Some(url.clone()),
295        current: url.clone(),
296        failure: None,
297        notify: None,
298    }));
299    let handle = WebHandle {
300        address: signal(url),
301        title: signal(String::new()),
302        loading: signal(false),
303        can_go_back: signal(false),
304        can_go_forward: signal(false),
305        error: signal(None),
306        view: signal(Rc::downgrade(&state)),
307    };
308    state.borrow_mut().handle = Some(handle);
309    connect(&state);
310    // The app setting the address opens it; the engine reporting a new one
311    // (a link followed, a redirect) changes nothing.
312    let weak = Rc::downgrade(&state);
313    watch(
314        move || handle.address.get(),
315        move |url, _| {
316            let Some(view) = weak.upgrade() else {
317                return;
318            };
319            let mut view = view.borrow_mut();
320            if url == view.current {
321                return;
322            }
323            view.current = url.clone();
324            if view.size.is_none() {
325                view.first = Some(url);
326                return;
327            }
328            view.act(|engine| engine.open(&url));
329        },
330    );
331    WebView {
332        state,
333        handle,
334        bar: true,
335        release: Vec::new(),
336    }
337}
338
339impl WebView {
340    /// The page's signals, and back, forward, reload and open.
341    pub fn handle(&self) -> WebHandle {
342        self.handle
343    }
344
345    /// Show the address bar (default) or not: an app may draw its own from
346    /// the [handle](Self::handle)'s signals.
347    #[must_use]
348    pub fn address_bar(mut self, on: bool) -> WebView {
349        self.bar = on;
350        self
351    }
352
353    /// Keys (space-separated names, as [`Node::on_key`] takes them) that
354    /// are not sent to the page but go on to the app's bindings.
355    #[must_use]
356    pub fn release_keys(mut self, keys: &str) -> WebView {
357        self.release
358            .extend(keys.split_whitespace().filter_map(Key::parse));
359        self
360    }
361
362    /// The view as a node: focusable, and flexible until sized.
363    pub fn node(self) -> Node {
364        widget(ViewWidget {
365            state: self.state,
366            handle: self.handle,
367            bar: self.bar,
368            release: self.release,
369            editing: None,
370            caret: None,
371        })
372    }
373}
374
375impl From<WebView> for Node {
376    fn from(view: WebView) -> Node {
377        view.node()
378    }
379}
380
381/// Where the address bar's parts are.
382const BACK: u16 = 1;
383const FORWARD: u16 = 3;
384const RELOAD: u16 = 5;
385const ADDRESS: u16 = 7;
386
387struct ViewWidget {
388    state: Rc<RefCell<ViewState>>,
389    handle: WebHandle,
390    bar: bool,
391    release: Vec<Key>,
392    /// The address being typed, while it is.
393    editing: Option<String>,
394    caret: Option<(u16, u16)>,
395}
396
397impl ViewWidget {
398    fn top(&self) -> u16 {
399        self.bar as u16
400    }
401
402    fn draw_bar(&mut self, cx: &mut DrawCx, canvas: &mut Canvas) {
403        let width = canvas.width();
404        let bar = cx.style("web.bar", "on grey15");
405        let off = bar.combine(&Style::parse("dim").unwrap_or_default());
406        canvas.fill(0, 0, width, 1, Some(&bar));
407        let back = self.handle.can_go_back.get();
408        let forward = self.handle.can_go_forward.get();
409        canvas.print(BACK, 0, "←", Some(if back { &bar } else { &off }));
410        canvas.print(FORWARD, 0, "→", Some(if forward { &bar } else { &off }));
411        canvas.print(RELOAD, 0, "↻", Some(&bar));
412        let loading = self.handle.loading.get();
413        let room = width.saturating_sub(ADDRESS + 2 * loading as u16);
414        match &self.editing {
415            Some(text) => {
416                let field = cx.style("web.address.edit", "on grey30");
417                canvas.fill(ADDRESS, 0, room, 1, Some(&field));
418                // The end of what is typed stays in view.
419                let shown = tail(text, room.saturating_sub(1));
420                canvas.print(ADDRESS, 0, &shown, Some(&field));
421                let at = ADDRESS + rich::cells::cell_len(&shown) as u16;
422                self.caret = Some((at.min(width.saturating_sub(1)), 0));
423            }
424            None => {
425                let address = self.handle.address.get();
426                let line = crate::web::fit(&address, room);
427                canvas.print(ADDRESS, 0, &line, Some(&bar));
428            }
429        }
430        if loading {
431            canvas.print(width.saturating_sub(2), 0, "…", Some(&bar));
432        }
433    }
434
435    fn input(&self, input: WebInput) {
436        let mut view = self.state.borrow_mut();
437        view.act(|engine| engine.input(input));
438    }
439
440    fn edit(&mut self, cx: &mut EventCx, key: Key) -> Used {
441        let Some(text) = &mut self.editing else {
442            return Used::No;
443        };
444        match key.code {
445            KeyCode::Enter => {
446                let url = std::mem::take(text).trim().to_string();
447                self.editing = None;
448                if !url.is_empty() {
449                    self.handle.open(with_scheme(&url));
450                }
451            }
452            KeyCode::Escape => self.editing = None,
453            KeyCode::Backspace => {
454                text.pop();
455            }
456            KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => text.push(c),
457            _ => {}
458        }
459        cx.redraw();
460        Used::Yes
461    }
462}
463
464impl Widget for ViewWidget {
465    fn name(&self) -> &'static str {
466        "web view"
467    }
468
469    fn describe(&self) -> Option<String> {
470        Some(self.state.borrow().current.clone())
471    }
472
473    fn draw(&mut self, cx: &mut DrawCx, canvas: &mut Canvas) {
474        let wake = self.state.borrow().wake;
475        wake.get();
476        self.caret = None;
477        let (width, height) = (canvas.width(), canvas.height());
478        let top = self.top();
479        if self.bar && height > 0 {
480            self.draw_bar(cx, canvas);
481        }
482        let rows = height.saturating_sub(top);
483        let mut view = self.state.borrow_mut();
484        view.fit(width, rows);
485        if rows == 0 {
486            return;
487        }
488        let error = self.handle.error.get().or_else(|| view.failure.clone());
489        match &view.frame {
490            Some(WebFrame::Cells { lines, cursor }) => {
491                canvas.lines_at(0, top, width, rows, lines);
492                if self.editing.is_none() {
493                    self.caret = cursor.map(|(x, y)| (x, y + top));
494                }
495            }
496            Some(WebFrame::Pixels(pixels)) => {
497                canvas.lines_at(0, top, width, rows, &half_blocks(pixels, width, rows));
498            }
499            None => {
500                canvas.clear(0, top, width, rows);
501                let note = match &error {
502                    Some(error) => format!("[red]{}", error.replace('[', "\\[")),
503                    None => "[dim]loading…".to_string(),
504                };
505                canvas.markup(cx.console(), 1, top, width.saturating_sub(1), &note, None);
506            }
507        }
508        if let (Some(error), Some(_)) = (error, &view.frame) {
509            let line = format!("[reverse red] {} ", error.replace('[', "\\["));
510            canvas.markup(cx.console(), 0, height - 1, width, &line, None);
511        }
512    }
513
514    fn event(&mut self, cx: &mut EventCx, event: &WidgetEvent) -> Used {
515        let top = self.top();
516        match event {
517            WidgetEvent::Key(key) if self.release.contains(key) && self.editing.is_none() => {
518                Used::No
519            }
520            WidgetEvent::Key(key) => {
521                if self.editing.is_some() {
522                    return self.edit(cx, *key);
523                }
524                let alt = key.modifiers.alt && !key.modifiers.ctrl;
525                match key.code {
526                    KeyCode::Char('l') if key.modifiers.ctrl && self.bar => {
527                        self.editing = Some(self.handle.address.get_untracked());
528                    }
529                    KeyCode::Left if alt => self.handle.back(),
530                    KeyCode::Right if alt => self.handle.forward(),
531                    KeyCode::F(5) => self.handle.reload(),
532                    _ => self.input(WebInput::Key(*key)),
533                }
534                cx.redraw();
535                Used::Yes
536            }
537            WidgetEvent::Paste(text) => {
538                match &mut self.editing {
539                    Some(editing) => editing.push_str(text.trim()),
540                    None => self.input(WebInput::Paste(text.clone())),
541                }
542                cx.redraw();
543                Used::Yes
544            }
545            WidgetEvent::Mouse(mouse) if self.bar && mouse.row == 0 => {
546                if mouse.kind != MouseKind::Down(Button::Left) {
547                    return Used::Yes;
548                }
549                match mouse.column {
550                    c if c == BACK => self.handle.back(),
551                    c if c == FORWARD => self.handle.forward(),
552                    c if c == RELOAD => self.handle.reload(),
553                    c if c >= ADDRESS && self.editing.is_none() => {
554                        self.editing = Some(self.handle.address.get_untracked());
555                    }
556                    _ => {}
557                }
558                cx.redraw();
559                Used::Yes
560            }
561            WidgetEvent::Mouse(mouse) => {
562                let mut page = *mouse;
563                page.row -= top;
564                self.input(WebInput::Mouse(page));
565                Used::Yes
566            }
567            WidgetEvent::Focus(false) if self.editing.is_some() => {
568                self.editing = None;
569                cx.redraw();
570                Used::Yes
571            }
572            _ => Used::No,
573        }
574    }
575
576    fn focusable(&self) -> bool {
577        true
578    }
579
580    fn caret(&self) -> Option<(u16, u16)> {
581        self.caret
582    }
583}
584
585/// `text` cut to `width` cells, with an ellipsis where it was cut.
586pub(crate) fn fit(text: &str, width: u16) -> String {
587    let width = width as usize;
588    if rich::cells::cell_len(text) <= width {
589        return text.to_string();
590    }
591    if width == 0 {
592        return String::new();
593    }
594    let mut out = String::new();
595    let mut used = 0;
596    for c in text.chars() {
597        let w = rich::cells::cell_len(c.encode_utf8(&mut [0u8; 4]));
598        if used + w + 1 > width {
599            break;
600        }
601        out.push(c);
602        used += w;
603    }
604    out.push('…');
605    out
606}
607
608/// The last `width` cells of `text`.
609fn tail(text: &str, width: u16) -> String {
610    let mut out: Vec<char> = Vec::new();
611    let mut used = 0;
612    for c in text.chars().rev() {
613        let w = rich::cells::cell_len(c.encode_utf8(&mut [0u8; 4]));
614        if used + w > width as usize {
615            break;
616        }
617        out.push(c);
618        used += w;
619    }
620    out.into_iter().rev().collect()
621}
622
623/// An address as typed, with `https://` when it names no scheme.
624fn with_scheme(url: &str) -> String {
625    if url.contains("://") || url.starts_with("about:") || url.starts_with("file:") {
626        url.to_string()
627    } else {
628        format!("https://{url}")
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    #[test]
637    fn addresses_fit_and_get_a_scheme() {
638        assert_eq!(fit("https://example.com", 10), "https://e…");
639        assert_eq!(fit("short", 10), "short");
640        assert_eq!(tail("abcdef", 3), "def");
641        assert_eq!(with_scheme("example.com"), "https://example.com");
642        assert_eq!(with_scheme("http://x"), "http://x");
643        assert_eq!(with_scheme("about:blank"), "about:blank");
644    }
645}