diff options
-rw-r--r-- | core/src/text.rs | 12 | ||||
-rw-r--r-- | examples/editor/src/main.rs | 2 | ||||
-rw-r--r-- | examples/markdown/Cargo.toml | 2 | ||||
-rw-r--r-- | examples/markdown/overview.md | 102 | ||||
-rw-r--r-- | examples/markdown/src/main.rs | 356 | ||||
-rw-r--r-- | highlighter/src/lib.rs | 37 | ||||
-rw-r--r-- | widget/src/helpers.rs | 4 | ||||
-rw-r--r-- | widget/src/text.rs | 2 | ||||
-rw-r--r-- | widget/src/text/rich.rs | 30 | ||||
-rw-r--r-- | widget/src/text_editor.rs | 29 |
10 files changed, 435 insertions, 141 deletions
diff --git a/core/src/text.rs b/core/src/text.rs index d73eb94a..22cfce13 100644 --- a/core/src/text.rs +++ b/core/src/text.rs @@ -267,12 +267,24 @@ impl<'a, Font> Span<'a, Font> { self } + /// Sets the font of the [`Span`], if any. + pub fn font_maybe(mut self, font: Option<impl Into<Font>>) -> Self { + self.font = font.map(Into::into); + self + } + /// Sets the [`Color`] of the [`Span`]. pub fn color(mut self, color: impl Into<Color>) -> Self { self.color = Some(color.into()); self } + /// Sets the [`Color`] of the [`Span`], if any. + pub fn color_maybe(mut self, color: Option<impl Into<Color>>) -> Self { + self.color = color.map(Into::into); + self + } + /// Turns the [`Span`] into a static one. pub fn to_static(self) -> Span<'static, Font> { Span { diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs index 71b1a719..9ffb4d1a 100644 --- a/examples/editor/src/main.rs +++ b/examples/editor/src/main.rs @@ -189,7 +189,7 @@ impl Editor { .highlight::<Highlighter>( highlighter::Settings { theme: self.theme, - extension: self + token: self .file .as_deref() .and_then(Path::extension) diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml index f9bf4042..6875ee94 100644 --- a/examples/markdown/Cargo.toml +++ b/examples/markdown/Cargo.toml @@ -7,6 +7,6 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["debug"] +iced.features = ["highlighter", "debug"] pulldown-cmark = "0.11" diff --git a/examples/markdown/overview.md b/examples/markdown/overview.md new file mode 100644 index 00000000..ca3250f1 --- /dev/null +++ b/examples/markdown/overview.md @@ -0,0 +1,102 @@ +# Overview + +Inspired by [The Elm Architecture], Iced expects you to split user interfaces +into four different concepts: + +* __State__ — the state of your application +* __Messages__ — user interactions or meaningful events that you care + about +* __View logic__ — a way to display your __state__ as widgets that + may produce __messages__ on user interaction +* __Update logic__ — a way to react to __messages__ and update your + __state__ + +We can build something to see how this works! Let's say we want a simple counter +that can be incremented and decremented using two buttons. + +We start by modelling the __state__ of our application: + +```rust +#[derive(Default)] +struct Counter { + value: i32, +} +``` + +Next, we need to define the possible user interactions of our counter: +the button presses. These interactions are our __messages__: + +```rust +#[derive(Debug, Clone, Copy)] +pub enum Message { + Increment, + Decrement, +} +``` + +Now, let's show the actual counter by putting it all together in our +__view logic__: + +```rust +use iced::widget::{button, column, text, Column}; + +impl Counter { + pub fn view(&self) -> Column<Message> { + // We use a column: a simple vertical layout + column![ + // The increment button. We tell it to produce an + // `Increment` message when pressed + button("+").on_press(Message::Increment), + + // We show the value of the counter here + text(self.value).size(50), + + // The decrement button. We tell it to produce a + // `Decrement` message when pressed + button("-").on_press(Message::Decrement), + ] + } +} +``` + +Finally, we need to be able to react to any produced __messages__ and change our +__state__ accordingly in our __update logic__: + +```rust +impl Counter { + // ... + + pub fn update(&mut self, message: Message) { + match message { + Message::Increment => { + self.value += 1; + } + Message::Decrement => { + self.value -= 1; + } + } + } +} +``` + +And that's everything! We just wrote a whole user interface. Let's run it: + +```rust +fn main() -> iced::Result { + iced::run("A cool counter", Counter::update, Counter::view) +} +``` + +Iced will automatically: + + 1. Take the result of our __view logic__ and layout its widgets. + 1. Process events from our system and produce __messages__ for our + __update logic__. + 1. Draw the resulting user interface. + +Read the [book], the [documentation], and the [examples] to learn more! + +[book]: https://book.iced.rs/ +[documentation]: https://docs.rs/iced/ +[examples]: https://github.com/iced-rs/iced/tree/master/examples#examples +[The Elm Architecture]: https://guide.elm-lang.org/architecture/ diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 43adaf72..384645fa 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -1,7 +1,6 @@ -use iced::font; -use iced::padding; use iced::widget::{ - self, column, container, rich_text, row, span, text_editor, + self, column, container, rich_text, row, scrollable, span, text, + text_editor, }; use iced::{Element, Fill, Font, Task, Theme}; @@ -13,6 +12,8 @@ pub fn main() -> iced::Result { struct Markdown { content: text_editor::Content, + items: Vec<Item>, + theme: Theme, } #[derive(Debug, Clone)] @@ -22,11 +23,15 @@ enum Message { impl Markdown { fn new() -> (Self, Task<Message>) { + const INITIAL_CONTENT: &str = include_str!("../overview.md"); + + let theme = Theme::TokyoNight; + ( Self { - content: text_editor::Content::with_text( - "# Markdown Editor\nType your Markdown here...", - ), + content: text_editor::Content::with_text(INITIAL_CONTENT), + items: parse(INITIAL_CONTENT, &theme).collect(), + theme, }, widget::focus_next(), ) @@ -34,7 +39,14 @@ impl Markdown { fn update(&mut self, message: Message) { match message { Message::Edit(action) => { + let is_edit = action.is_edit(); + self.content.perform(action); + + if is_edit { + self.items = + parse(&self.content.text(), &self.theme).collect(); + } } } } @@ -46,127 +58,225 @@ impl Markdown { .padding(10) .font(Font::MONOSPACE); - let preview = { - let markdown = self.content.text(); - let parser = pulldown_cmark::Parser::new(&markdown); - - let mut strong = false; - let mut emphasis = false; - let mut heading = None; - let mut spans = Vec::new(); - - let items = parser.filter_map(|event| match event { - pulldown_cmark::Event::Start(tag) => match tag { - pulldown_cmark::Tag::Strong => { - strong = true; - None - } - pulldown_cmark::Tag::Emphasis => { - emphasis = true; - None - } - pulldown_cmark::Tag::Heading { level, .. } => { - heading = Some(level); - None - } - _ => None, - }, - pulldown_cmark::Event::End(tag) => match tag { - pulldown_cmark::TagEnd::Emphasis => { - emphasis = false; - None - } - pulldown_cmark::TagEnd::Strong => { - strong = false; - None - } - pulldown_cmark::TagEnd::Heading(_) => { - heading = None; - Some( - container(rich_text(spans.drain(..))) - .padding(padding::bottom(5)) - .into(), - ) - } - pulldown_cmark::TagEnd::Paragraph => Some( - container(rich_text(spans.drain(..))) - .padding(padding::bottom(15)) - .into(), - ), - pulldown_cmark::TagEnd::CodeBlock => Some( - container( - container( - rich_text(spans.drain(..)) - .font(Font::MONOSPACE), - ) - .width(Fill) - .padding(10) - .style(container::rounded_box), - ) - .padding(padding::bottom(15)) - .into(), - ), - _ => None, - }, - pulldown_cmark::Event::Text(text) => { - let span = span(text.into_string()); - - let span = match heading { - None => span, - Some(heading) => span.size(match heading { - pulldown_cmark::HeadingLevel::H1 => 32, - pulldown_cmark::HeadingLevel::H2 => 28, - pulldown_cmark::HeadingLevel::H3 => 24, - pulldown_cmark::HeadingLevel::H4 => 20, - pulldown_cmark::HeadingLevel::H5 => 16, - pulldown_cmark::HeadingLevel::H6 => 16, - }), - }; - - let span = if strong || emphasis { - span.font(Font { - weight: if strong { - font::Weight::Bold - } else { - font::Weight::Normal - }, - style: if emphasis { - font::Style::Italic - } else { - font::Style::Normal - }, - ..Font::default() - }) - } else { - span - }; - - spans.push(span); - - None - } - pulldown_cmark::Event::Code(code) => { - spans.push(span(code.into_string()).font(Font::MONOSPACE)); - None - } - pulldown_cmark::Event::SoftBreak => { - spans.push(span(" ")); - None - } - pulldown_cmark::Event::HardBreak => { - spans.push(span("\n")); - None - } - _ => None, - }); - - column(items).width(Fill) - }; + let preview = markdown(&self.items); - row![editor, preview].spacing(10).padding(10).into() + row![ + editor, + scrollable(preview).spacing(10).width(Fill).height(Fill) + ] + .spacing(10) + .padding(10) + .into() } fn theme(&self) -> Theme { Theme::TokyoNight } } + +fn markdown<'a>( + items: impl IntoIterator<Item = &'a Item>, +) -> Element<'a, Message> { + use iced::padding; + + 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(|item| row!["•", rich_text(item)].spacing(10).into()), + ) + .spacing(10) + .into(), + Item::List { + start: Some(start), + items, + } => column(items.iter().enumerate().map(|(i, item)| { + row![text!("{}.", i as u64 + *start), rich_text(item)] + .spacing(10) + .into() + })) + .spacing(10) + .into(), + Item::CodeBlock(code) => { + container(rich_text(code).font(Font::MONOSPACE).size(12)) + .width(Fill) + .padding(10) + .style(container::rounded_box) + .into() + } + }); + + column(blocks).width(Fill).spacing(16).into() +} + +#[derive(Debug, Clone)] +enum Item { + Heading(Vec<text::Span<'static>>), + Paragraph(Vec<text::Span<'static>>), + CodeBlock(Vec<text::Span<'static>>), + List { + start: Option<u64>, + items: Vec<Vec<text::Span<'static>>>, + }, +} + +fn parse<'a>( + markdown: &'a str, + theme: &'a Theme, +) -> impl Iterator<Item = Item> + 'a { + use iced::font; + use iced::highlighter::{self, Highlighter}; + use text::Highlighter as _; + + let mut spans = Vec::new(); + let mut heading = None; + let mut strong = false; + let mut emphasis = false; + let mut link = false; + let mut list = Vec::new(); + let mut list_start = None; + let mut highlighter = None; + + let parser = pulldown_cmark::Parser::new(markdown); + + // We want to keep the `spans` capacity + #[allow(clippy::drain_collect)] + parser.filter_map(move |event| match event { + pulldown_cmark::Event::Start(tag) => match tag { + pulldown_cmark::Tag::Heading { level, .. } => { + heading = Some(level); + None + } + pulldown_cmark::Tag::Strong => { + strong = true; + None + } + pulldown_cmark::Tag::Emphasis => { + emphasis = true; + None + } + pulldown_cmark::Tag::Link { .. } => { + link = true; + None + } + pulldown_cmark::Tag::List(first_item) => { + list_start = first_item; + None + } + pulldown_cmark::Tag::CodeBlock( + pulldown_cmark::CodeBlockKind::Fenced(language), + ) => { + highlighter = Some(Highlighter::new(&highlighter::Settings { + theme: highlighter::Theme::Base16Ocean, + token: language.to_string(), + })); + + None + } + _ => None, + }, + pulldown_cmark::Event::End(tag) => match tag { + pulldown_cmark::TagEnd::Heading(_) => { + heading = None; + Some(Item::Heading(spans.drain(..).collect())) + } + pulldown_cmark::TagEnd::Emphasis => { + emphasis = false; + None + } + pulldown_cmark::TagEnd::Strong => { + strong = false; + None + } + pulldown_cmark::TagEnd::Link => { + link = false; + None + } + pulldown_cmark::TagEnd::Paragraph => { + Some(Item::Paragraph(spans.drain(..).collect())) + } + pulldown_cmark::TagEnd::List(_) => Some(Item::List { + start: list_start, + items: list.drain(..).collect(), + }), + pulldown_cmark::TagEnd::Item => { + list.push(spans.drain(..).collect()); + None + } + pulldown_cmark::TagEnd::CodeBlock => { + highlighter = None; + Some(Item::CodeBlock(spans.drain(..).collect())) + } + _ => None, + }, + pulldown_cmark::Event::Text(text) => { + if let Some(highlighter) = &mut highlighter { + for (range, highlight) in + highlighter.highlight_line(text.as_ref()) + { + let span = span(text[range].to_owned()) + .color_maybe(highlight.color()) + .font_maybe(highlight.font()); + + spans.push(span); + } + } else { + let span = span(text.into_string()); + + let span = match heading { + None => span, + Some(heading) => span.size(match heading { + pulldown_cmark::HeadingLevel::H1 => 32, + pulldown_cmark::HeadingLevel::H2 => 28, + pulldown_cmark::HeadingLevel::H3 => 24, + pulldown_cmark::HeadingLevel::H4 => 20, + pulldown_cmark::HeadingLevel::H5 => 16, + pulldown_cmark::HeadingLevel::H6 => 16, + }), + }; + + let span = if strong || emphasis { + span.font(Font { + weight: if strong { + font::Weight::Bold + } else { + font::Weight::Normal + }, + style: if emphasis { + font::Style::Italic + } else { + font::Style::Normal + }, + ..Font::default() + }) + } else { + span + }; + + let span = + span.color_maybe(link.then(|| theme.palette().primary)); + + spans.push(span); + } + + None + } + pulldown_cmark::Event::Code(code) => { + spans.push(span(code.into_string()).font(Font::MONOSPACE)); + None + } + pulldown_cmark::Event::SoftBreak => { + spans.push(span(" ")); + None + } + pulldown_cmark::Event::HardBreak => { + spans.push(span("\n")); + None + } + _ => None, + }) +} diff --git a/highlighter/src/lib.rs b/highlighter/src/lib.rs index 7636a712..deee199f 100644 --- a/highlighter/src/lib.rs +++ b/highlighter/src/lib.rs @@ -1,8 +1,9 @@ //! A syntax highlighter for iced. use iced_core as core; +use crate::core::font::{self, Font}; use crate::core::text::highlighter::{self, Format}; -use crate::core::{Color, Font}; +use crate::core::Color; use once_cell::sync::Lazy; use std::ops::Range; @@ -35,7 +36,7 @@ impl highlighter::Highlighter for Highlighter { fn new(settings: &Self::Settings) -> Self { let syntax = SYNTAXES - .find_syntax_by_token(&settings.extension) + .find_syntax_by_token(&settings.token) .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()); let highlighter = highlighting::Highlighter::new( @@ -55,7 +56,7 @@ impl highlighter::Highlighter for Highlighter { fn update(&mut self, new_settings: &Self::Settings) { self.syntax = SYNTAXES - .find_syntax_by_token(&new_settings.extension) + .find_syntax_by_token(&new_settings.token) .unwrap_or_else(|| SYNTAXES.find_syntax_plain_text()); self.highlighter = highlighting::Highlighter::new( @@ -141,11 +142,11 @@ pub struct Settings { /// /// It dictates the color scheme that will be used for highlighting. pub theme: Theme, - /// The extension of the file to highlight. + /// The extension of the file or the name of the language to highlight. /// - /// The [`Highlighter`] will use the extension to automatically determine + /// The [`Highlighter`] will use the token to automatically determine /// the grammar to use for highlighting. - pub extension: String, + pub token: String, } /// A highlight produced by a [`Highlighter`]. @@ -166,7 +167,29 @@ impl Highlight { /// /// If `None`, the original font should be unchanged. pub fn font(&self) -> Option<Font> { - None + self.0.font_style.and_then(|style| { + let bold = style.contains(highlighting::FontStyle::BOLD); + + let italic = style.contains(highlighting::FontStyle::ITALIC); + + if bold || italic { + Some(Font { + weight: if bold { + font::Weight::Bold + } else { + font::Weight::Normal + }, + style: if italic { + font::Style::Italic + } else { + font::Style::Normal + }, + ..Font::MONOSPACE + }) + } else { + None + } + }) } /// Returns the [`Format`] of the [`Highlight`]. diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 66b37ccb..0390079f 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -24,7 +24,7 @@ use crate::tooltip::{self, Tooltip}; use crate::vertical_slider::{self, VerticalSlider}; use crate::{Column, MouseArea, Row, Space, Stack, Themer}; -use std::borrow::Borrow; +use std::borrow::{Borrow, Cow}; use std::ops::RangeInclusive; /// Creates a [`Column`] with the given children. @@ -684,7 +684,7 @@ where /// /// [`Rich`]: text::Rich pub fn rich_text<'a, Theme, Renderer>( - spans: impl IntoIterator<Item = text::Span<'a, Renderer::Font>>, + spans: impl Into<Cow<'a, [text::Span<'a, Renderer::Font>]>>, ) -> text::Rich<'a, Theme, Renderer> where Theme: text::Catalog + 'a, diff --git a/widget/src/text.rs b/widget/src/text.rs index c32f9be1..9bf7fce4 100644 --- a/widget/src/text.rs +++ b/widget/src/text.rs @@ -1,7 +1,7 @@ //! Draw and interact with text. mod rich; -pub use crate::core::text::{Fragment, IntoFragment, Span}; +pub use crate::core::text::{Fragment, Highlighter, IntoFragment, Span}; pub use crate::core::widget::text::*; pub use rich::Rich; diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index dc784310..5c44ed9e 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -11,6 +11,8 @@ use crate::core::{ self, Color, Element, Length, Pixels, Rectangle, Size, Widget, }; +use std::borrow::Cow; + /// A bunch of [`Rich`] text. #[derive(Debug)] pub struct Rich<'a, Theme = crate::Theme, Renderer = crate::Renderer> @@ -18,7 +20,7 @@ where Theme: Catalog, Renderer: core::text::Renderer, { - spans: Vec<Span<'a, Renderer::Font>>, + spans: Cow<'a, [Span<'a, Renderer::Font>]>, size: Option<Pixels>, line_height: LineHeight, width: Length, @@ -37,7 +39,7 @@ where /// Creates a new empty [`Rich`] text. pub fn new() -> Self { Self { - spans: Vec::new(), + spans: Cow::default(), size: None, line_height: LineHeight::default(), width: Length::Shrink, @@ -51,10 +53,10 @@ where /// Creates a new [`Rich`] text with the given text spans. pub fn with_spans( - spans: impl IntoIterator<Item = Span<'a, Renderer::Font>>, + spans: impl Into<Cow<'a, [Span<'a, Renderer::Font>]>>, ) -> Self { Self { - spans: spans.into_iter().collect(), + spans: spans.into(), ..Self::new() } } @@ -151,7 +153,7 @@ where /// Adds a new text [`Span`] to the [`Rich`] text. pub fn push(mut self, span: impl Into<Span<'a, Renderer::Font>>) -> Self { - self.spans.push(span.into()); + self.spans.to_mut().push(span.into()); self } } @@ -207,7 +209,7 @@ where limits, self.width, self.height, - self.spans.as_slice(), + self.spans.as_ref(), self.line_height, self.size, self.font, @@ -303,6 +305,22 @@ where }) } +impl<'a, Theme, Renderer> FromIterator<Span<'a, Renderer::Font>> + for Rich<'a, Theme, Renderer> +where + Theme: Catalog, + Renderer: core::text::Renderer, +{ + fn from_iter<T: IntoIterator<Item = Span<'a, Renderer::Font>>>( + spans: T, + ) -> Self { + Self { + spans: spans.into_iter().collect(), + ..Self::new() + } + } +} + impl<'a, Message, Theme, Renderer> From<Rich<'a, Theme, Renderer>> for Element<'a, Message, Theme, Renderer> where diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index 0156b960..e494a3b0 100644 --- a/widget/src/text_editor.rs +++ b/widget/src/text_editor.rs @@ -9,6 +9,7 @@ use crate::core::renderer; use crate::core::text::editor::{Cursor, Editor as _}; use crate::core::text::highlighter::{self, Highlighter}; use crate::core::text::{self, LineHeight}; +use crate::core::widget::operation; use crate::core::widget::{self, Widget}; use crate::core::{ Background, Border, Color, Element, Length, Padding, Pixels, Rectangle, @@ -338,6 +339,22 @@ impl<Highlighter: text::Highlighter> State<Highlighter> { } } +impl<Highlighter: text::Highlighter> operation::Focusable + for State<Highlighter> +{ + fn is_focused(&self) -> bool { + self.is_focused + } + + fn focus(&mut self) { + self.is_focused = true; + } + + fn unfocus(&mut self) { + self.is_focused = false; + } +} + impl<'a, Highlighter, Message, Theme, Renderer> Widget<Message, Theme, Renderer> for TextEditor<'a, Highlighter, Message, Theme, Renderer> where @@ -640,6 +657,18 @@ where mouse::Interaction::default() } } + + fn operate( + &self, + tree: &mut widget::Tree, + _layout: Layout<'_>, + _renderer: &Renderer, + operation: &mut dyn widget::Operation<()>, + ) { + let state = tree.state.downcast_mut::<State<Highlighter>>(); + + operation.focusable(state, None); + } } impl<'a, Highlighter, Message, Theme, Renderer> |