cursive_markup/
lib.rs

1// SPDX-FileCopyrightText: 2020 Robin Krahl <robin.krahl@ireas.org>
2// SPDX-License-Identifier: Apache-2.0 or MIT
3
4//! `cursive-markup` provides the [`MarkupView`][] for [`cursive`][] that can render HTML or other
5//! markup.
6//!
7//! # Quickstart
8//!
9//! To render an HTML document, create a [`MarkupView`][] with the [`html`][] method, configure the
10//! maximum line width using the [`set_maximum_width`][] method and set callbacks for the links
11//! using the [`on_link_select`][] and [`on_link_focus`][] methods.
12//!
13//! Typically, you’ll want to wrap the view in a [`ScrollView`][] and add it to a
14//! [`Cursive`][`cursive::Cursive`] instance.
15//!
16//! ```
17//! // Create the markup view
18//! let html = "<a href='https://rust-lang.org'>Rust</a>";
19//! let mut view = cursive_markup::MarkupView::html(&html);
20//! view.set_maximum_width(120);
21//!
22//! // Set callbacks that are called if the link focus is changed and if a link is selected with
23//! // the Enter key
24//! view.on_link_focus(|s, url| {});
25//! view.on_link_select(|s, url| {});
26//!
27//! // Add the view to a Cursive instance
28//! use cursive::view::{Resizable, Scrollable};
29//! let mut s = cursive::dummy();
30//! s.add_global_callback('q', |s| s.quit());
31//! s.add_fullscreen_layer(view.scrollable().full_screen());
32//! s.run();
33//! ```
34//!
35//! You can use the arrow keys to navigate between the links and press Enter to trigger the
36//! [`on_link_select`][] callback.
37//!
38//! For a complete example, see [`examples/browser.rs`][], a very simple browser implementation.
39//!
40//! # Components
41//!
42//! The main component of the crate is [`MarkupView`][].  It is a [`cursive`][] view that displays
43//! hypertext: a combination of formatted text and links.  You can use the arrow keys to navigate
44//! between the links, and the Enter key to select a link.
45//!
46//! The displayed content is provided and rendered by a [`Renderer`][] instance.  If the `html`
47//! feature is enabled (default), the [`html::Renderer`][] can be used to parse and render an HTML
48//! document with [`html2text`][].  But you can also implement your own [`Renderer`][].
49//! [`MarkupView`][] caches the rendered document ([`RenderedDocument`][]) and only invokes the
50//! renderer if the width of the view has been changed.
51//!
52//! ## HTML rendering
53//!
54//! To customize the HTML rendering, you can change the [`TextDecorator`][] that is used by
55//! [`html2text`][] to transform the HTML DOM into annotated strings.  Of course the renderer must
56//! know how to interpret the annotations, so if you provide a custom decorator, you also have to
57//! provide a [`Converter`][] that extracts formatting and links from the annotations.
58//!
59//! [`cursive`]: https://docs.rs/cursive/latest/cursive/
60//! [`cursive::Cursive`]: https://docs.rs/cursive/latest/cursive/struct.Cursive.html
61//! [`ScrollView`]: https://docs.rs/cursive/latest/cursive/views/struct.ScrollView.html
62//! [`html2text`]: https://docs.rs/html2text/latest/html2text/
63//! [`TextDecorator`]: https://docs.rs/html2text/latest/html2text/render/text_renderer/trait.TextDecorator.html
64//! [`Converter`]: html/trait.Converter.html
65//! [`MarkupView`]: struct.MarkupView.html
66//! [`RenderedDocument`]: struct.RenderedDocument.html
67//! [`Renderer`]: trait.Renderer.html
68//! [`html`]: struct.MarkupView.html#method.html
69//! [`set_maximum_width`]: struct.MarkupView.html#method.set_maximum_width
70//! [`on_link_select`]: struct.MarkupView.html#method.on_link_select
71//! [`on_link_focus`]: struct.MarkupView.html#method.on_link_focus
72//! [`html::Renderer`]: html/struct.Renderer.html
73//! [`examples/browser.rs`]: https://git.sr.ht/~ireas/cursive-markup-rs/tree/master/examples/browser.rs
74
75#![warn(missing_docs, rust_2018_idioms)]
76
77#[cfg(feature = "html")]
78pub mod html;
79
80use std::rc;
81
82use cursive_core::theme;
83use unicode_width::UnicodeWidthStr as _;
84
85/// A view for hypertext that has been rendered by a [`Renderer`][].
86///
87/// This view displays hypertext (a combination of formatted text and links) that typically has
88/// been parsed from a markup language.  You can use the arrow keys to navigate between the links,
89/// and the Enter key to select a link.  If the focused link is changed, the [`on_link_focus`][]
90/// callback is triggered.  If the focused link is selected using the Enter key, the
91/// [`on_link_select`][] callback is triggered.
92///
93/// The displayed hypertext is created by a [`Renderer`][] implementation.  The `MarkupView` calls
94/// the [`render`][] method with the size constraint provided by `cursive` and receives a
95/// [`RenderedDocument`][] that contains the text and the links.  This document is cached until the
96/// available width changes.
97///
98/// You can also limit the available width by setting a maximum line width with the
99/// [`set_maximum_width`][] method.
100///
101/// [`RenderedDocument`]: struct.RenderedDocument.html
102/// [`Renderer`]: trait.Renderer.html
103/// [`render`]: trait.Renderer.html#method.render
104/// [`on_link_select`]: #method.on_link_select
105/// [`on_link_focus`]: #method.on_link_focus
106/// [`set_maximum_width`]: #method.set_maximum_width
107pub struct MarkupView<R: Renderer + 'static> {
108    renderer: R,
109    doc: Option<RenderedDocument>,
110    on_link_focus: Option<rc::Rc<LinkCallback>>,
111    on_link_select: Option<rc::Rc<LinkCallback>>,
112    maximum_width: Option<usize>,
113}
114
115/// A callback that is triggered for a link.
116///
117/// The first argument is a mutable reference to the current [`Cursive`][] instance.  The second
118/// argument is the target of the link, typically a URL.
119///
120/// [`Cursive`]: https://docs.rs/cursive/latest/cursive/struct.Cursive.html
121pub type LinkCallback = dyn Fn(&mut cursive_core::Cursive, &str);
122
123/// A renderer that produces a hypertext document.
124pub trait Renderer {
125    /// Renders this document within the given size constraint and returns the result.
126    ///
127    /// This method is called by [`MarkupView`][] every time the provided width changes.
128    ///
129    /// [`MarkupView`]: struct.MarkupView.html
130    fn render(&self, constraint: cursive_core::XY<usize>) -> RenderedDocument;
131}
132
133/// A rendered hypertext document that consists of lines of formatted text and links.
134#[derive(Clone, Debug)]
135pub struct RenderedDocument {
136    lines: Vec<Vec<RenderedElement>>,
137    link_handler: LinkHandler,
138    size: cursive_core::XY<usize>,
139    constraint: cursive_core::XY<usize>,
140}
141
142/// A hypertext element: a formatted string with an optional link target.
143#[derive(Clone, Debug, Default)]
144pub struct Element {
145    text: String,
146    style: theme::Style,
147    link_target: Option<String>,
148}
149
150#[derive(Clone, Debug, Default)]
151struct RenderedElement {
152    text: String,
153    style: theme::Style,
154    link_idx: Option<usize>,
155}
156
157#[derive(Clone, Debug, Default)]
158struct LinkHandler {
159    links: Vec<Link>,
160    focus: usize,
161}
162
163#[derive(Clone, Debug)]
164struct Link {
165    position: cursive_core::XY<usize>,
166    width: usize,
167    target: String,
168}
169
170#[cfg(feature = "html")]
171impl MarkupView<html::RichRenderer> {
172    /// Creates a new `MarkupView` that uses a rich text HTML renderer.
173    ///
174    /// *Requires the `html` feature (enabled per default).*
175    pub fn html(html: &str) -> MarkupView<html::RichRenderer> {
176        MarkupView::with_renderer(html::Renderer::new(html))
177    }
178}
179
180impl<R: Renderer + 'static> MarkupView<R> {
181    /// Creates a new `MarkupView` with the given renderer.
182    pub fn with_renderer(renderer: R) -> MarkupView<R> {
183        MarkupView {
184            renderer,
185            doc: None,
186            on_link_focus: None,
187            on_link_select: None,
188            maximum_width: None,
189        }
190    }
191
192    /// Sets the callback that is triggered if the link focus is changed.
193    ///
194    /// Note that this callback is only triggered if the link focus is changed with the arrow keys.
195    /// It is not triggered if the view takes focus.  The callback will receive the target of the
196    /// link as an argument.
197    pub fn on_link_focus<F: Fn(&mut cursive_core::Cursive, &str) + 'static>(&mut self, f: F) {
198        self.on_link_focus = Some(rc::Rc::new(f));
199    }
200
201    /// Sets the callback that is triggered if a link is selected.
202    ///
203    /// This callback is triggered if a link is focused and the users presses the Enter key.  The
204    /// callback will receive the target of the link as an argument.
205    pub fn on_link_select<F: Fn(&mut cursive_core::Cursive, &str) + 'static>(&mut self, f: F) {
206        self.on_link_select = Some(rc::Rc::new(f));
207    }
208
209    /// Sets the maximum width of the view.
210    ///
211    /// This means that the width that is available for the renderer is limited to the given value.
212    pub fn set_maximum_width(&mut self, width: usize) {
213        self.maximum_width = Some(width);
214    }
215
216    fn render(&mut self, mut constraint: cursive_core::XY<usize>) -> cursive_core::XY<usize> {
217        let mut last_focus = 0;
218
219        if let Some(width) = self.maximum_width {
220            constraint.x = std::cmp::min(width, constraint.x);
221        }
222
223        if let Some(doc) = &self.doc {
224            if constraint.x == doc.constraint.x {
225                return doc.size;
226            }
227            last_focus = doc.link_handler.focus;
228        }
229
230        let mut doc = self.renderer.render(constraint);
231
232        // TODO: Rendering the document with a different width may lead to links being split up (or
233        // previously split up links being no longer split up).  Ideally, we would adjust the focus
234        // for these changes.
235        if last_focus < doc.link_handler.links.len() {
236            doc.link_handler.focus = last_focus;
237        }
238        let size = doc.size;
239        self.doc = Some(doc);
240        size
241    }
242}
243
244impl<R: Renderer + 'static> cursive_core::View for MarkupView<R> {
245    fn draw(&self, printer: &cursive_core::Printer<'_, '_>) {
246        let doc = &self.doc.as_ref().expect("layout not called before draw");
247        for (y, line) in doc.lines.iter().enumerate() {
248            let mut x = 0;
249            for element in line {
250                let mut style = element.style;
251                if let Some(link_idx) = element.link_idx {
252                    if printer.focused && doc.link_handler.focus == link_idx {
253                        style = style.combine(theme::PaletteColor::Highlight);
254                    }
255                }
256                printer.with_style(style, |printer| printer.print((x, y), &element.text));
257                x += element.text.width();
258            }
259        }
260    }
261
262    fn layout(&mut self, constraint: cursive_core::XY<usize>) {
263        self.render(constraint);
264    }
265
266    fn required_size(&mut self, constraint: cursive_core::XY<usize>) -> cursive_core::XY<usize> {
267        self.render(constraint)
268    }
269
270    fn take_focus(
271        &mut self,
272        direction: cursive_core::direction::Direction,
273    ) -> Result<cursive_core::event::EventResult, cursive_core::view::CannotFocus> {
274        self.doc
275            .as_mut()
276            .map(|doc| doc.link_handler.take_focus(direction))
277            .unwrap_or(Err(cursive_core::view::CannotFocus))
278    }
279
280    fn on_event(&mut self, event: cursive_core::event::Event) -> cursive_core::event::EventResult {
281        use cursive_core::direction::Absolute;
282        use cursive_core::event::{Callback, Event, EventResult, Key};
283
284        let link_handler = if let Some(doc) = self.doc.as_mut() {
285            if doc.link_handler.links.is_empty() {
286                return EventResult::Ignored;
287            } else {
288                &mut doc.link_handler
289            }
290        } else {
291            return EventResult::Ignored;
292        };
293
294        // TODO: implement mouse support
295
296        let focus_changed = match event {
297            Event::Key(Key::Left) => link_handler.move_focus(Absolute::Left),
298            Event::Key(Key::Right) => link_handler.move_focus(Absolute::Right),
299            Event::Key(Key::Up) => link_handler.move_focus(Absolute::Up),
300            Event::Key(Key::Down) => link_handler.move_focus(Absolute::Down),
301            _ => false,
302        };
303
304        if focus_changed {
305            let target = link_handler.links[link_handler.focus].target.clone();
306            EventResult::Consumed(
307                self.on_link_focus
308                    .clone()
309                    .map(|f| Callback::from_fn(move |s| f(s, &target))),
310            )
311        } else if event == Event::Key(Key::Enter) {
312            let target = link_handler.links[link_handler.focus].target.clone();
313            EventResult::Consumed(
314                self.on_link_select
315                    .clone()
316                    .map(|f| Callback::from_fn(move |s| f(s, &target))),
317            )
318        } else {
319            EventResult::Ignored
320        }
321    }
322
323    fn important_area(&self, _: cursive_core::XY<usize>) -> cursive_core::Rect {
324        if let Some(doc) = &self.doc {
325            doc.link_handler.important_area()
326        } else {
327            cursive_core::Rect::from_point((0, 0))
328        }
329    }
330}
331
332impl RenderedDocument {
333    /// Creates a new rendered document with the given size constraint.
334    ///
335    /// The size constraint is used to check whether a cached document can be reused or whether it
336    /// has to be rendered for the new constraint.  It is *not* enforced by this struct!
337    pub fn new(constraint: cursive_core::XY<usize>) -> RenderedDocument {
338        RenderedDocument {
339            lines: Vec::new(),
340            link_handler: Default::default(),
341            size: (0, 0).into(),
342            constraint,
343        }
344    }
345
346    /// Appends a rendered line to the document.
347    pub fn push_line<I: IntoIterator<Item = Element>>(&mut self, line: I) {
348        let mut rendered_line = Vec::new();
349        let y = self.lines.len();
350        let mut x = 0;
351        for element in line {
352            let width = element.text.width();
353            let link_idx = element.link_target.map(|target| {
354                self.link_handler.push(Link {
355                    position: (x, y).into(),
356                    width,
357                    target,
358                })
359            });
360            x += width;
361            rendered_line.push(RenderedElement {
362                text: element.text,
363                style: element.style,
364                link_idx,
365            });
366        }
367        self.lines.push(rendered_line);
368        self.size = self.size.stack_vertical(&(x, 1).into());
369    }
370}
371
372impl Element {
373    /// Creates a new element with the given text, style and optional link target.
374    pub fn new(text: String, style: theme::Style, link_target: Option<String>) -> Element {
375        Element {
376            text,
377            style,
378            link_target,
379        }
380    }
381
382    /// Creates an element with the given text, with the default style and without a link target.
383    pub fn plain(text: String) -> Element {
384        Element {
385            text,
386            ..Default::default()
387        }
388    }
389
390    /// Creates an element with the given text and style and without a link target.
391    pub fn styled(text: String, style: theme::Style) -> Element {
392        Element::new(text, style, None)
393    }
394
395    /// Creates an element with the given text, style and link target.
396    pub fn link(text: String, style: theme::Style, target: String) -> Element {
397        Element::new(text, style, Some(target))
398    }
399}
400
401impl From<String> for Element {
402    fn from(s: String) -> Element {
403        Element::plain(s)
404    }
405}
406
407impl From<Element> for RenderedElement {
408    fn from(element: Element) -> RenderedElement {
409        RenderedElement {
410            text: element.text,
411            style: element.style,
412            link_idx: None,
413        }
414    }
415}
416
417impl LinkHandler {
418    pub fn push(&mut self, link: Link) -> usize {
419        self.links.push(link);
420        self.links.len() - 1
421    }
422
423    pub fn take_focus(
424        &mut self,
425        direction: cursive_core::direction::Direction,
426    ) -> Result<cursive_core::event::EventResult, cursive_core::view::CannotFocus> {
427        if self.links.is_empty() {
428            Err(cursive_core::view::CannotFocus)
429        } else {
430            use cursive_core::direction::{Absolute, Direction, Relative};
431            let rel = match direction {
432                Direction::Abs(abs) => match abs {
433                    Absolute::Up | Absolute::Left | Absolute::None => Relative::Front,
434                    Absolute::Down | Absolute::Right => Relative::Back,
435                },
436                Direction::Rel(rel) => rel,
437            };
438            self.focus = match rel {
439                Relative::Front => 0,
440                Relative::Back => self.links.len() - 1,
441            };
442            Ok(cursive_core::event::EventResult::consumed())
443        }
444    }
445
446    pub fn move_focus(&mut self, direction: cursive_core::direction::Absolute) -> bool {
447        use cursive_core::direction::{Absolute, Relative};
448
449        match direction {
450            Absolute::Left => self.move_focus_horizontal(Relative::Front),
451            Absolute::Right => self.move_focus_horizontal(Relative::Back),
452            Absolute::Up => self.move_focus_vertical(Relative::Front),
453            Absolute::Down => self.move_focus_vertical(Relative::Back),
454            Absolute::None => false,
455        }
456    }
457
458    fn move_focus_horizontal(&mut self, direction: cursive_core::direction::Relative) -> bool {
459        use cursive_core::direction::Relative;
460
461        if self.links.is_empty() {
462            return false;
463        }
464
465        let new_focus = match direction {
466            Relative::Front => self.focus.checked_sub(1),
467            Relative::Back => {
468                if self.focus < self.links.len() - 1 {
469                    Some(self.focus + 1)
470                } else {
471                    None
472                }
473            }
474        };
475
476        if let Some(new_focus) = new_focus {
477            if self.links[self.focus].position.y == self.links[new_focus].position.y {
478                self.focus = new_focus;
479                true
480            } else {
481                false
482            }
483        } else {
484            false
485        }
486    }
487
488    fn move_focus_vertical(&mut self, direction: cursive_core::direction::Relative) -> bool {
489        use cursive_core::direction::Relative;
490
491        if self.links.is_empty() {
492            return false;
493        }
494
495        // TODO: Currently, we select the first link on a different line.  We could instead select
496        // the closest link on a different line (if there are multiple links on one line).
497
498        let y = self.links[self.focus].position.y;
499        let iter = self.links.iter().enumerate();
500        let next = match direction {
501            Relative::Front => iter
502                .rev()
503                .skip(self.links.len() - self.focus)
504                .find(|(_, link)| link.position.y < y),
505            Relative::Back => iter
506                .skip(self.focus + 1)
507                .find(|(_, link)| link.position.y > y),
508        };
509
510        if let Some((idx, _)) = next {
511            self.focus = idx;
512            true
513        } else {
514            false
515        }
516    }
517
518    pub fn important_area(&self) -> cursive_core::Rect {
519        if self.links.is_empty() {
520            cursive_core::Rect::from_point((0, 0))
521        } else {
522            let link = &self.links[self.focus];
523            cursive_core::Rect::from_size(link.position, (link.width, 1))
524        }
525    }
526}