Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Link support to rich_text widget #2512

Merged
merged 5 commits into from
Jul 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions core/src/renderer/null.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ impl text::Paragraph for () {

fn with_text(_text: Text<&str>) -> Self {}

fn with_spans(
_text: Text<&[text::Span<'_, Self::Font>], Self::Font>,
fn with_spans<Link>(
_text: Text<&[text::Span<'_, Link, Self::Font>], Self::Font>,
) -> Self {
}

Expand Down Expand Up @@ -107,6 +107,10 @@ impl text::Paragraph for () {
fn hit_test(&self, _point: Point) -> Option<text::Hit> {
None
}

fn hit_span(&self, _point: Point) -> Option<usize> {
None
}
}

impl text::Editor for () {
Expand Down
34 changes: 30 additions & 4 deletions core/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,8 @@ pub trait Renderer: crate::Renderer {
}

/// A span of text.
#[derive(Debug, Clone, PartialEq)]
pub struct Span<'a, Font = crate::Font> {
#[derive(Debug, Clone)]
pub struct Span<'a, Link = (), Font = crate::Font> {
/// The [`Fragment`] of text.
pub text: Fragment<'a>,
/// The size of the [`Span`] in [`Pixels`].
Expand All @@ -235,9 +235,11 @@ pub struct Span<'a, Font = crate::Font> {
pub font: Option<Font>,
/// The [`Color`] of the [`Span`].
pub color: Option<Color>,
/// The link of the [`Span`].
pub link: Option<Link>,
}

impl<'a, Font> Span<'a, Font> {
impl<'a, Link, Font> Span<'a, Link, Font> {
/// Creates a new [`Span`] of text with the given text fragment.
pub fn new(fragment: impl IntoFragment<'a>) -> Self {
Self {
Expand All @@ -246,6 +248,7 @@ impl<'a, Font> Span<'a, Font> {
line_height: None,
font: None,
color: None,
link: None,
}
}

Expand Down Expand Up @@ -285,14 +288,27 @@ impl<'a, Font> Span<'a, Font> {
self
}

/// Sets the link of the [`Span`].
pub fn link(mut self, link: impl Into<Link>) -> Self {
self.link = Some(link.into());
self
}

/// Sets the link of the [`Span`], if any.
pub fn link_maybe(mut self, link: Option<impl Into<Link>>) -> Self {
self.link = link.map(Into::into);
self
}

/// Turns the [`Span`] into a static one.
pub fn to_static(self) -> Span<'static, Font> {
pub fn to_static(self) -> Span<'static, Link, Font> {
Span {
text: Cow::Owned(self.text.into_owned()),
size: self.size,
line_height: self.line_height,
font: self.font,
color: self.color,
link: self.link,
}
}
}
Expand All @@ -303,6 +319,16 @@ impl<'a, Font> From<&'a str> for Span<'a, Font> {
}
}

impl<'a, Link, Font: PartialEq> PartialEq for Span<'a, Link, Font> {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
&& self.size == other.size
&& self.line_height == other.line_height
&& self.font == other.font
&& self.color == other.color
}
}

/// A fragment of [`Text`].
///
/// This is just an alias to a string that may be either
Expand Down
9 changes: 8 additions & 1 deletion core/src/text/paragraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ pub trait Paragraph: Sized + Default {
fn with_text(text: Text<&str, Self::Font>) -> Self;

/// Creates a new [`Paragraph`] laid out with the given [`Text`].
fn with_spans(text: Text<&[Span<'_, Self::Font>], Self::Font>) -> Self;
fn with_spans<Link>(
text: Text<&[Span<'_, Link, Self::Font>], Self::Font>,
) -> Self;

/// Lays out the [`Paragraph`] with some new boundaries.
fn resize(&mut self, new_bounds: Size);
Expand All @@ -35,6 +37,11 @@ pub trait Paragraph: Sized + Default {
/// [`Paragraph`], returning information about the nearest character.
fn hit_test(&self, point: Point) -> Option<Hit>;

/// Tests whether the provided point is within the boundaries of a
/// [`Span`] in the [`Paragraph`], returning the index of the [`Span`]
/// that was hit.
fn hit_span(&self, point: Point) -> Option<usize>;

/// Returns the distance to the given grapheme index in the [`Paragraph`].
fn grapheme_position(&self, line: usize, index: usize) -> Option<Point>;

Expand Down
2 changes: 2 additions & 0 deletions examples/markdown/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ publish = false
[dependencies]
iced.workspace = true
iced.features = ["markdown", "highlighter", "debug"]

open = "5.3"
6 changes: 5 additions & 1 deletion examples/markdown/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct Markdown {
#[derive(Debug, Clone)]
enum Message {
Edit(text_editor::Action),
LinkClicked(String),
}

impl Markdown {
Expand Down Expand Up @@ -50,6 +51,9 @@ impl Markdown {
.collect();
}
}
Message::LinkClicked(link) => {
let _ = open::that_in_background(link);
}
}
}

Expand All @@ -60,7 +64,7 @@ impl Markdown {
.padding(10)
.font(Font::MONOSPACE);

let preview = markdown(&self.items);
let preview = markdown(&self.items, Message::LinkClicked);

row![editor, scrollable(preview).spacing(10).height(Fill)]
.spacing(10)
Expand Down
50 changes: 35 additions & 15 deletions graphics/src/text/paragraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ impl core::text::Paragraph for Paragraph {
}))
}

fn with_spans(text: Text<&[Span<'_>]>) -> Self {
log::trace!("Allocating rich paragraph: {:?}", text.content);
fn with_spans<Link>(text: Text<&[Span<'_, Link>]>) -> Self {
log::trace!("Allocating rich paragraph: {} spans", text.content.len());

let mut font_system =
text::font_system().write().expect("Write font system");
Expand All @@ -122,18 +122,8 @@ impl core::text::Paragraph for Paragraph {

buffer.set_rich_text(
font_system.raw(),
text.content.iter().map(|span| {
let attrs = cosmic_text::Attrs::new();

let attrs = if let Some(font) = span.font {
attrs
.family(text::to_family(font.family))
.weight(text::to_weight(font.weight))
.stretch(text::to_stretch(font.stretch))
.style(text::to_style(font.style))
} else {
text::to_attributes(text.font)
};
text.content.iter().enumerate().map(|(i, span)| {
let attrs = text::to_attributes(span.font.unwrap_or(text.font));

let attrs = match (span.size, span.line_height) {
(None, None) => attrs,
Expand All @@ -156,7 +146,7 @@ impl core::text::Paragraph for Paragraph {
attrs
};

(span.text.as_ref(), attrs)
(span.text.as_ref(), attrs.metadata(i))
}),
text::to_attributes(text.font),
text::to_shaping(text.shaping),
Expand Down Expand Up @@ -231,6 +221,36 @@ impl core::text::Paragraph for Paragraph {
Some(Hit::CharOffset(cursor.index))
}

fn hit_span(&self, point: Point) -> Option<usize> {
let internal = self.internal();

let cursor = internal.buffer.hit(point.x, point.y)?;
let line = internal.buffer.lines.get(cursor.line)?;

let mut last_glyph = None;
let mut glyphs = line
.layout_opt()
.as_ref()?
.iter()
.flat_map(|line| line.glyphs.iter())
.peekable();

while let Some(glyph) = glyphs.peek() {
if glyph.start <= cursor.index && cursor.index < glyph.end {
break;
}

last_glyph = glyphs.next();
}

let glyph = match cursor.affinity {
cosmic_text::Affinity::Before => last_glyph,
cosmic_text::Affinity::After => glyphs.next(),
}?;

Some(glyph.metadata)
}

fn grapheme_position(&self, line: usize, index: usize) -> Option<Point> {
use unicode_segmentation::UnicodeSegmentation;

Expand Down
7 changes: 4 additions & 3 deletions widget/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,10 +683,11 @@ where
/// Creates a new [`Rich`] text widget with the provided spans.
///
/// [`Rich`]: text::Rich
pub fn rich_text<'a, Theme, Renderer>(
spans: impl Into<Cow<'a, [text::Span<'a, Renderer::Font>]>>,
) -> text::Rich<'a, Theme, Renderer>
pub fn rich_text<'a, Message, Link, Theme, Renderer>(
spans: impl Into<Cow<'a, [text::Span<'a, Link, Renderer::Font>]>>,
) -> text::Rich<'a, Message, Link, Theme, Renderer>
where
Link: Clone,
Theme: text::Catalog + 'a,
Renderer: core::text::Renderer,
{
Expand Down
68 changes: 41 additions & 27 deletions widget/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ use crate::{column, container, rich_text, row, span, text};
#[derive(Debug, Clone)]
pub enum Item {
/// A heading.
Heading(Vec<text::Span<'static>>),
Heading(Vec<text::Span<'static, String>>),
/// A paragraph.
Paragraph(Vec<text::Span<'static>>),
Paragraph(Vec<text::Span<'static, String>>),
/// A code block.
///
/// You can enable the `highlighter` feature for syntax highligting.
CodeBlock(Vec<text::Span<'static>>),
CodeBlock(Vec<text::Span<'static, String>>),
/// A list.
List {
/// The first number of the list, if it is ordered.
Expand All @@ -46,7 +46,7 @@ pub fn parse(
let mut emphasis = false;
let mut metadata = false;
let mut table = false;
let mut link = false;
let mut link = None;
let mut lists = Vec::new();

#[cfg(feature = "highlighter")]
Expand Down Expand Up @@ -93,8 +93,10 @@ pub fn parse(
emphasis = true;
None
}
pulldown_cmark::Tag::Link { .. } if !metadata && !table => {
link = true;
pulldown_cmark::Tag::Link { dest_url, .. }
if !metadata && !table =>
{
link = Some(dest_url);
None
}
pulldown_cmark::Tag::List(first_item) if !metadata && !table => {
Expand Down Expand Up @@ -150,7 +152,7 @@ pub fn parse(
None
}
pulldown_cmark::TagEnd::Link if !metadata && !table => {
link = false;
link = None;
None
}
pulldown_cmark::TagEnd::Paragraph if !metadata && !table => {
Expand Down Expand Up @@ -245,7 +247,11 @@ pub fn parse(
span
};

let span = span.color_maybe(link.then_some(palette.primary));
let span = if let Some(link) = link.as_ref() {
span.color(palette.primary).link(link.to_string())
} else {
span
};

spans.push(span);

Expand All @@ -272,40 +278,48 @@ pub fn parse(
/// You can obtain the items with [`parse`].
pub fn view<'a, Message, Renderer>(
items: impl IntoIterator<Item = &'a Item>,
on_link: impl Fn(String) -> Message + Copy + 'a,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Renderer: core::text::Renderer<Font = Font> + 'a,
{
let blocks = items.into_iter().enumerate().map(|(i, item)| match item {
Item::Heading(heading) => container(rich_text(heading))
.padding(padding::top(if i > 0 { 8 } else { 0 }))
.into(),
Item::Paragraph(paragraph) => rich_text(paragraph).into(),
Item::List { start: None, items } => column(
items
.iter()
.map(|items| row!["•", view(items)].spacing(10).into()),
)
.spacing(10)
.into(),
Item::Heading(heading) => {
container(rich_text(heading).on_link(on_link))
.padding(padding::top(if i > 0 { 8 } else { 0 }))
.into()
}
Item::Paragraph(paragraph) => {
rich_text(paragraph).on_link(on_link).into()
}
Item::List { start: None, items } => {
column(items.iter().map(|items| {
row!["•", view(items, on_link)].spacing(10).into()
}))
.spacing(10)
.into()
}
Item::List {
start: Some(start),
items,
} => column(items.iter().enumerate().map(|(i, items)| {
row![text!("{}.", i as u64 + *start), view(items)]
row![text!("{}.", i as u64 + *start), view(items, on_link)]
.spacing(10)
.into()
}))
.spacing(10)
.into(),
Item::CodeBlock(code) => {
container(rich_text(code).font(Font::MONOSPACE).size(12))
.width(Length::Fill)
.padding(10)
.style(container::rounded_box)
.into()
}
Item::CodeBlock(code) => container(
rich_text(code)
.font(Font::MONOSPACE)
.size(12)
.on_link(on_link),
)
.width(Length::Fill)
.padding(10)
.style(container::rounded_box)
.into(),
});

Element::new(column(blocks).width(Length::Fill).spacing(16))
Expand Down
Loading
Loading