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}