diff --git a/api/cpp/cbindgen.rs b/api/cpp/cbindgen.rs index d546f1b9e6b..7c82bf908be 100644 --- a/api/cpp/cbindgen.rs +++ b/api/cpp/cbindgen.rs @@ -305,7 +305,6 @@ fn gen_corelib( "Flickable", "SimpleText", "ComplexText", - "MarkdownText", "Path", "WindowItem", "TextInput", diff --git a/internal/backends/testing/testing_backend.rs b/internal/backends/testing/testing_backend.rs index 02fbfa46609..1534e2c76d0 100644 --- a/internal/backends/testing/testing_backend.rs +++ b/internal/backends/testing/testing_backend.rs @@ -3,6 +3,7 @@ use i_slint_core::api::PhysicalSize; use i_slint_core::graphics::euclid::{Point2D, Size2D}; +use i_slint_core::item_rendering::PlainOrStyledText; use i_slint_core::lengths::{LogicalLength, LogicalPoint, LogicalRect, LogicalSize}; use i_slint_core::platform::PlatformError; use i_slint_core::renderer::{Renderer, RendererSealed}; @@ -164,7 +165,11 @@ impl RendererSealed for TestingWindow { _max_width: Option, _text_wrap: TextWrap, ) -> LogicalSize { - LogicalSize::new(text_item.text().len() as f32 * 10., 10.) + if let PlainOrStyledText::Plain(text) = text_item.text() { + LogicalSize::new(text.len() as f32 * 10., 10.) + } else { + Default::default() + } } fn char_size( diff --git a/internal/compiler/builtins.slint b/internal/compiler/builtins.slint index 688c44c68b2..b28261cc075 100644 --- a/internal/compiler/builtins.slint +++ b/internal/compiler/builtins.slint @@ -120,10 +120,10 @@ component ComplexText inherits SimpleText { export { ComplexText as Text } -export component MarkdownText inherits Empty { +component StyledTextItem inherits Empty { in property width; in property height; - in property text; + in property text; in property font-size; in property font-weight; in property color; @@ -143,6 +143,8 @@ export component MarkdownText inherits Empty { //-default_size_binding:implicit_size } +export { StyledTextItem as StyledText } + export component TouchArea { in property enabled: true; out property pressed; diff --git a/internal/compiler/expression_tree.rs b/internal/compiler/expression_tree.rs index f3b7dfb44fc..5149a6dde84 100644 --- a/internal/compiler/expression_tree.rs +++ b/internal/compiler/expression_tree.rs @@ -114,6 +114,8 @@ pub enum BuiltinFunction { StopTimer, RestartTimer, OpenUrl, + ParseMarkdown, + EscapeMarkdown, } #[derive(Debug, Clone)] @@ -273,7 +275,9 @@ declare_builtin_function_types!( StartTimer: (Type::ElementReference) -> Type::Void, StopTimer: (Type::ElementReference) -> Type::Void, RestartTimer: (Type::ElementReference) -> Type::Void, - OpenUrl: (Type::String) -> Type::Void + OpenUrl: (Type::String) -> Type::Void, + EscapeMarkdown: (Type::String) -> Type::String, + ParseMarkdown: (Type::String) -> Type::StyledText ); impl BuiltinFunction { @@ -370,6 +374,7 @@ impl BuiltinFunction { BuiltinFunction::StopTimer => false, BuiltinFunction::RestartTimer => false, BuiltinFunction::OpenUrl => false, + BuiltinFunction::ParseMarkdown | BuiltinFunction::EscapeMarkdown => false, } } @@ -448,6 +453,7 @@ impl BuiltinFunction { BuiltinFunction::StopTimer => false, BuiltinFunction::RestartTimer => false, BuiltinFunction::OpenUrl => false, + BuiltinFunction::ParseMarkdown | BuiltinFunction::EscapeMarkdown => true, } } } diff --git a/internal/compiler/generator/cpp.rs b/internal/compiler/generator/cpp.rs index ca32f7d1859..17b88090ff4 100644 --- a/internal/compiler/generator/cpp.rs +++ b/internal/compiler/generator/cpp.rs @@ -4213,6 +4213,14 @@ fn compile_builtin_function_call( let url = a.next().unwrap(); format!("slint::cbindgen_private::open_url({})", url) } + BuiltinFunction::EscapeMarkdown => { + let text = a.next().unwrap(); + format!("slint::cbindgen_private::escape_markdown({})", text) + } + BuiltinFunction::ParseMarkdown => { + let text = a.next().unwrap(); + format!("slint::cbindgen_private::parse_markdown({})", text) + } } } diff --git a/internal/compiler/generator/rust.rs b/internal/compiler/generator/rust.rs index 31fc5efeae0..19c9015fef6 100644 --- a/internal/compiler/generator/rust.rs +++ b/internal/compiler/generator/rust.rs @@ -3383,6 +3383,14 @@ fn compile_builtin_function_call( let url = a.next().unwrap(); quote!(sp::open_url(&#url)) } + BuiltinFunction::EscapeMarkdown => { + let text = a.next().unwrap(); + quote!(sp::escape_markdown(&#text)) + } + BuiltinFunction::ParseMarkdown => { + let text = a.next().unwrap(); + quote!(sp::parse_markdown(&#text)) + } } } diff --git a/internal/compiler/llr/optim_passes/inline_expressions.rs b/internal/compiler/llr/optim_passes/inline_expressions.rs index def5c8aa8c7..00d21366a38 100644 --- a/internal/compiler/llr/optim_passes/inline_expressions.rs +++ b/internal/compiler/llr/optim_passes/inline_expressions.rs @@ -155,6 +155,7 @@ fn builtin_function_cost(function: &BuiltinFunction) -> isize { BuiltinFunction::StopTimer => 10, BuiltinFunction::RestartTimer => 10, BuiltinFunction::OpenUrl => isize::MAX, + BuiltinFunction::ParseMarkdown | BuiltinFunction::EscapeMarkdown => isize::MAX, } } diff --git a/internal/compiler/parser.rs b/internal/compiler/parser.rs index 42138b6058f..3cb3a9a37e8 100644 --- a/internal/compiler/parser.rs +++ b/internal/compiler/parser.rs @@ -383,6 +383,7 @@ declare_syntax! { AtGradient -> [*Expression], /// `@tr("foo", ...)` // the string is a StringLiteral AtTr -> [?TrContext, ?TrPlural, *Expression], + AtMarkdown -> [*Expression], /// `"foo" =>` in a `AtTr` node TrContext -> [], /// `| "foo" % n` in a `AtTr` node diff --git a/internal/compiler/parser/expressions.rs b/internal/compiler/parser/expressions.rs index 0d15afdebfc..350c4f3e295 100644 --- a/internal/compiler/parser/expressions.rs +++ b/internal/compiler/parser/expressions.rs @@ -255,6 +255,9 @@ fn parse_at_keyword(p: &mut impl Parser) { "tr" => { parse_tr(p); } + "markdown" => { + parse_markdown(p); + } _ => { p.consume(); p.test(SyntaxKind::Identifier); // consume the identifier, so that autocomplete works @@ -438,6 +441,37 @@ fn parse_tr(p: &mut impl Parser) { p.expect(SyntaxKind::RParent); } +fn parse_markdown(p: &mut impl Parser) { + let mut p = p.start_node(SyntaxKind::AtMarkdown); + p.expect(SyntaxKind::At); + debug_assert!(p.peek().as_str().ends_with("markdown")); + p.expect(SyntaxKind::Identifier); //eg "markdown" + p.expect(SyntaxKind::LParent); + + fn consume_literal(p: &mut impl Parser) -> bool { + let peek = p.peek(); + if peek.kind() != SyntaxKind::StringLiteral + || !peek.as_str().starts_with('"') + || !peek.as_str().ends_with('"') + { + p.error("Expected plain string literal"); + return false; + } + p.expect(SyntaxKind::StringLiteral) + } + + if !consume_literal(&mut *p) { + return; + } + + while p.test(SyntaxKind::Comma) { + if !parse_expression(&mut *p) { + break; + } + } + p.expect(SyntaxKind::RParent); +} + #[cfg_attr(test, parser_test)] /// ```test,AtImageUrl /// @image-url("foo.png") diff --git a/internal/compiler/passes/apply_default_properties_from_style.rs b/internal/compiler/passes/apply_default_properties_from_style.rs index 6595f7374f4..725e323c8cb 100644 --- a/internal/compiler/passes/apply_default_properties_from_style.rs +++ b/internal/compiler/passes/apply_default_properties_from_style.rs @@ -62,7 +62,7 @@ pub fn apply_default_properties_from_style( } }); } - "Text" | "MarkdownText" => { + "Text" | "StyledText" => { elem.set_binding_if_not_set("color".into(), || Expression::Cast { from: Expression::PropertyReference(NamedReference::new( &palette.root_element, diff --git a/internal/compiler/passes/embed_glyphs.rs b/internal/compiler/passes/embed_glyphs.rs index 0fda26c25a2..8d695401da1 100644 --- a/internal/compiler/passes/embed_glyphs.rs +++ b/internal/compiler/passes/embed_glyphs.rs @@ -546,7 +546,7 @@ pub fn collect_font_sizes_used( .to_string() .as_str() { - "TextInput" | "Text" | "SimpleText" | "ComplexText" | "MarkdownText" => { + "TextInput" | "Text" | "SimpleText" | "ComplexText" | "StyledText" => { if let Some(font_size) = try_extract_font_size_from_element(elem, "font-size") { add_font_size(font_size) } diff --git a/internal/compiler/passes/resolving.rs b/internal/compiler/passes/resolving.rs index 8b84658f71b..b895751ca18 100644 --- a/internal/compiler/passes/resolving.rs +++ b/internal/compiler/passes/resolving.rs @@ -359,6 +359,7 @@ impl Expression { SyntaxKind::AtImageUrl => Some(Self::from_at_image_url_node(node.into(), ctx)), SyntaxKind::AtGradient => Some(Self::from_at_gradient(node.into(), ctx)), SyntaxKind::AtTr => Some(Self::from_at_tr(node.into(), ctx)), + SyntaxKind::AtMarkdown => Some(Self::from_at_markdown(node.into(), ctx)), SyntaxKind::QualifiedName => Some(Self::from_qualified_name_node( node.clone().into(), ctx, @@ -745,6 +746,143 @@ impl Expression { } } + fn from_at_markdown(node: syntax_nodes::AtMarkdown, ctx: &mut LookupCtx) -> Expression { + let Some(string) = node + .child_text(SyntaxKind::StringLiteral) + .and_then(|s| crate::literals::unescape_string(&s)) + else { + ctx.diag.push_error("Cannot parse string literal".into(), &node); + return Expression::Invalid; + }; + + let subs = node.Expression().map(|n| { + Expression::from_expression_node(n.clone(), ctx).maybe_convert_to( + Type::String, + &n, + ctx.diag, + ) + }); + let values = subs.collect::>(); + + let mut expr = None; + + // check format string + { + let mut arg_idx = 0; + let mut pos_max = 0; + let mut pos = 0; + let mut literal_start_pos = 0; + while let Some(mut p) = string[pos..].find(['{', '}']) { + if string.len() - pos < p + 1 { + ctx.diag.push_error( + "Unescaped trailing '{' in format string. Escape '{' with '{{'".into(), + &node, + ); + break; + } + p += pos; + + // Skip escaped } + if string.get(p..=p) == Some("}") { + if string.get(p + 1..=p + 1) == Some("}") { + pos = p + 2; + continue; + } else { + ctx.diag.push_error( + "Unescaped '}' in format string. Escape '}' with '}}'".into(), + &node, + ); + break; + } + } + + // Skip escaped { + if string.get(p + 1..=p + 1) == Some("{") { + pos = p + 2; + continue; + } + + // Find the argument + let end = if let Some(end) = string[p..].find('}') { + end + p + } else { + ctx.diag.push_error( + "Unterminated placeholder in format string. '{' must be escaped with '{{'" + .into(), + &node, + ); + break; + }; + let argument = &string[p + 1..end]; + let argument_index = if argument.is_empty() { + let argument_index = arg_idx; + arg_idx += 1; + argument_index + } else if let Ok(n) = argument.parse::() { + pos_max = pos_max.max(n as usize + 1); + n as usize + } else { + ctx.diag + .push_error("Invalid '{...}' placeholder in format string. The placeholder must be a number, or braces must be escaped with '{{' and '}}'".into(), &node); + break; + }; + let add = Expression::BinaryExpression { + lhs: Box::new(Expression::StringLiteral( + (&string[literal_start_pos..p]).into(), + )), + op: '+', + rhs: Box::new(Expression::FunctionCall { + function: BuiltinFunction::EscapeMarkdown.into(), + arguments: vec![values[argument_index].clone()], + source_location: Some(node.to_source_location()), + }), + }; + expr = Some(match expr { + None => add, + Some(expr) => Expression::BinaryExpression { + lhs: Box::new(expr), + op: '+', + rhs: Box::new(add), + }, + }); + pos = end + 1; + literal_start_pos = pos; + } + let trailing = &string[literal_start_pos..]; + if !trailing.is_empty() { + let trailing = Expression::StringLiteral(trailing.into()); + expr = Some(match expr { + None => trailing, + Some(expr) => Expression::BinaryExpression { + lhs: Box::new(expr), + op: '+', + rhs: Box::new(trailing), + }, + }); + } + if arg_idx > 0 && pos_max > 0 { + ctx.diag.push_error( + "Cannot mix positional and non-positional placeholder in format string".into(), + &node, + ); + } else if arg_idx > values.len() || pos_max > values.len() { + let num = arg_idx.max(pos_max); + ctx.diag.push_error( + format!("Format string contains {num} placeholders, but only {} extra arguments were given", values.len()), + &node, + ); + } + } + + Expression::FunctionCall { + function: BuiltinFunction::ParseMarkdown.into(), + arguments: vec![ + expr.unwrap_or_else(|| Expression::default_value_for_type(&Type::String)), + ], + source_location: Some(node.to_source_location()), + } + } + fn from_at_tr(node: syntax_nodes::AtTr, ctx: &mut LookupCtx) -> Expression { let Some(string) = node .child_text(SyntaxKind::StringLiteral) diff --git a/internal/compiler/typeregister.rs b/internal/compiler/typeregister.rs index 89005e92267..c1b8e213609 100644 --- a/internal/compiler/typeregister.rs +++ b/internal/compiler/typeregister.rs @@ -586,7 +586,7 @@ impl TypeRegister { register.elements.remove("DropArea").unwrap(); register.types.remove("DropEvent").unwrap(); // Also removed in xtask/src/slintdocs.rs - register.elements.remove("MarkdownText").unwrap(); + register.elements.remove("StyledText").unwrap(); Rc::new(RefCell::new(register)) } diff --git a/internal/core/item_rendering.rs b/internal/core/item_rendering.rs index 4900ec3da54..b7b5b62840b 100644 --- a/internal/core/item_rendering.rs +++ b/internal/core/item_rendering.rs @@ -296,10 +296,16 @@ pub trait HasFont { fn font_request(self: Pin<&Self>, self_rc: &crate::items::ItemRc) -> FontRequest; } +#[allow(missing_docs)] +pub enum PlainOrStyledText { + Plain(SharedString), + Styled(crate::api::StyledText), +} + /// Trait for an item that represents an string towards the renderer #[allow(missing_docs)] pub trait RenderString: HasFont { - fn text(self: Pin<&Self>) -> SharedString; + fn text(self: Pin<&Self>) -> PlainOrStyledText; } /// Trait for an item that represents an Text towards the renderer @@ -329,8 +335,8 @@ impl HasFont for (SharedString, Brush) { } impl RenderString for (SharedString, Brush) { - fn text(self: Pin<&Self>) -> SharedString { - self.0.clone() + fn text(self: Pin<&Self>) -> PlainOrStyledText { + PlainOrStyledText::Plain(self.0.clone()) } } diff --git a/internal/core/items.rs b/internal/core/items.rs index 08326bd75dc..f1851d4e978 100644 --- a/internal/core/items.rs +++ b/internal/core/items.rs @@ -1729,7 +1729,7 @@ declare_item_vtable! { } declare_item_vtable! { - fn slint_get_MarkdownTextVTable() -> MarkdownTextVTable for MarkdownText + fn slint_get_StyledTextItemVTable() -> StyledTextItemVTable for StyledTextItem } declare_item_vtable! { diff --git a/internal/core/items/text.rs b/internal/core/items/text.rs index 39d9018d879..b40177dd567 100644 --- a/internal/core/items/text.rs +++ b/internal/core/items/text.rs @@ -13,12 +13,15 @@ use super::{ TextHorizontalAlignment, TextOverflow, TextStrokeStyle, TextVerticalAlignment, TextWrap, VoidArg, WindowItem, }; +use crate::api; use crate::graphics::{Brush, Color, FontRequest}; use crate::input::{ FocusEvent, FocusEventResult, FocusReason, InputEventFilterResult, InputEventResult, KeyEvent, KeyboardModifiers, MouseEvent, StandardShortcut, TextShortcut, key_codes, }; -use crate::item_rendering::{CachedRenderingData, HasFont, ItemRenderer, RenderString, RenderText}; +use crate::item_rendering::{ + CachedRenderingData, HasFont, ItemRenderer, PlainOrStyledText, RenderString, RenderText, +}; use crate::layout::{LayoutInfo, Orientation}; use crate::lengths::{LogicalLength, LogicalPoint, LogicalRect, LogicalSize}; use crate::platform::Clipboard; @@ -169,8 +172,8 @@ impl HasFont for ComplexText { } impl RenderString for ComplexText { - fn text(self: Pin<&Self>) -> SharedString { - self.text() + fn text(self: Pin<&Self>) -> PlainOrStyledText { + PlainOrStyledText::Plain(self.text()) } } @@ -225,10 +228,10 @@ impl ComplexText { #[repr(C)] #[derive(FieldOffsets, Default, SlintElement)] #[pin] -pub struct MarkdownText { +pub struct StyledTextItem { pub width: Property, pub height: Property, - pub text: Property, + pub text: Property, pub font_size: Property, pub font_weight: Property, pub color: Property, @@ -248,7 +251,7 @@ pub struct MarkdownText { pub cached_rendering_data: CachedRenderingData, } -impl Item for MarkdownText { +impl Item for StyledTextItem { fn init(self: Pin<&Self>, _self_rc: &ItemRc) {} fn layout_info( @@ -368,14 +371,14 @@ impl Item for MarkdownText { } } -impl ItemConsts for MarkdownText { +impl ItemConsts for StyledTextItem { const cached_rendering_data_offset: const_field_offset::FieldOffset< - MarkdownText, + StyledTextItem, CachedRenderingData, - > = MarkdownText::FIELD_OFFSETS.cached_rendering_data.as_unpinned_projection(); + > = StyledTextItem::FIELD_OFFSETS.cached_rendering_data.as_unpinned_projection(); } -impl HasFont for MarkdownText { +impl HasFont for StyledTextItem { fn font_request(self: Pin<&Self>, self_rc: &crate::items::ItemRc) -> FontRequest { crate::items::WindowItem::resolved_font_request( self_rc, @@ -388,13 +391,13 @@ impl HasFont for MarkdownText { } } -impl RenderString for MarkdownText { - fn text(self: Pin<&Self>) -> SharedString { - self.text() +impl RenderString for StyledTextItem { + fn text(self: Pin<&Self>) -> PlainOrStyledText { + PlainOrStyledText::Styled(self.text()) } } -impl RenderText for MarkdownText { +impl RenderText for StyledTextItem { fn target_size(self: Pin<&Self>) -> LogicalSize { LogicalSize::from_lengths(self.width(), self.height()) } @@ -430,7 +433,7 @@ impl RenderText for MarkdownText { } } -impl MarkdownText { +impl StyledTextItem { pub fn font_metrics( self: Pin<&Self>, window_adapter: &Rc, @@ -566,8 +569,8 @@ impl HasFont for SimpleText { } impl RenderString for SimpleText { - fn text(self: Pin<&Self>) -> SharedString { - self.text() + fn text(self: Pin<&Self>) -> PlainOrStyledText { + PlainOrStyledText::Plain(self.text()) } } @@ -1268,8 +1271,8 @@ impl HasFont for TextInput { } impl RenderString for TextInput { - fn text(self: Pin<&Self>) -> SharedString { - self.as_ref().text() + fn text(self: Pin<&Self>) -> PlainOrStyledText { + PlainOrStyledText::Plain(self.as_ref().text()) } } diff --git a/internal/core/lib.rs b/internal/core/lib.rs index 62c7c284e89..e08f8e90ba5 100644 --- a/internal/core/lib.rs +++ b/internal/core/lib.rs @@ -172,3 +172,33 @@ pub fn open_url(url: &str) { debug_log!("Error opening url {}: {}", url, err); } } + +pub fn escape_markdown(text: &str) -> alloc::string::String { + let mut out = alloc::string::String::with_capacity(text.len()); + + for c in text.chars() { + match c { + '*' => out.push_str("\\*"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '_' => out.push_str("\\_"), + '#' => out.push_str("\\#"), + '-' => out.push_str("\\-"), + '`' => out.push_str("\\`"), + '&' => out.push_str("\\&"), + _ => out.push(c), + } + } + + out +} + +#[cfg_attr(not(feature = "experimental-rich-text"), allow(unused))] +pub fn parse_markdown(text: &str) -> crate::api::StyledText { + #[cfg(feature = "experimental-rich-text")] + { + crate::api::StyledText::parse(text).unwrap() + } + #[cfg(not(feature = "experimental-rich-text"))] + Default::default() +} diff --git a/internal/core/software_renderer.rs b/internal/core/software_renderer.rs index 4d714d69713..d6a9456d70b 100644 --- a/internal/core/software_renderer.rs +++ b/internal/core/software_renderer.rs @@ -22,7 +22,8 @@ use crate::api::PlatformError; use crate::graphics::rendering_metrics_collector::{RefreshMode, RenderingMetricsCollector}; use crate::graphics::{BorderRadius, Rgba8Pixel, SharedImageBuffer, SharedPixelBuffer}; use crate::item_rendering::{ - CachedRenderingData, ItemRenderer, RenderBorderRectangle, RenderImage, RenderRectangle, + CachedRenderingData, ItemRenderer, PlainOrStyledText, RenderBorderRectangle, RenderImage, + RenderRectangle, }; use crate::item_tree::ItemTreeWeak; use crate::items::{ItemRc, TextOverflow, TextWrap}; @@ -743,15 +744,14 @@ impl RendererSealed for SoftwareRenderer { let font_request = text_item.font_request(item_rc); let font = fonts::match_font(&font_request, scale_factor); - match (font, parley_disabled()) { + match (font, parley_disabled(), text_item.text()) { #[cfg(feature = "software-renderer-systemfonts")] - (fonts::Font::VectorFont(_), false) => { + (fonts::Font::VectorFont(_), false, _) => { sharedparley::text_size(self, text_item, item_rc, max_width, text_wrap) } #[cfg(feature = "software-renderer-systemfonts")] - (fonts::Font::VectorFont(vf), true) => { + (fonts::Font::VectorFont(vf), true, PlainOrStyledText::Plain(text)) => { let layout = fonts::text_layout_for_font(&vf, &font_request, scale_factor); - let text = text_item.text(); let (longest_line_width, height) = layout.text_size( text.as_str(), max_width.map(|max_width| (max_width.cast() * scale_factor).cast()), @@ -760,9 +760,8 @@ impl RendererSealed for SoftwareRenderer { (PhysicalSize::from_lengths(longest_line_width, height).cast() / scale_factor) .cast() } - (fonts::Font::PixelFont(pf), _) => { + (fonts::Font::PixelFont(pf), _, PlainOrStyledText::Plain(text)) => { let layout = fonts::text_layout_for_font(&pf, &font_request, scale_factor); - let text = text_item.text(); let (longest_line_width, height) = layout.text_size( text.as_str(), max_width.map(|max_width| (max_width.cast() * scale_factor).cast()), @@ -771,6 +770,10 @@ impl RendererSealed for SoftwareRenderer { (PhysicalSize::from_lengths(longest_line_width, height).cast() / scale_factor) .cast() } + (_, true, PlainOrStyledText::Styled(_)) + | (fonts::Font::PixelFont(_), _, PlainOrStyledText::Styled(_)) => { + panic!("Unable to get text size of styled text without parley") + } } } @@ -2530,14 +2533,13 @@ impl crate::item_rendering::ItemRenderer for SceneBuilder<'_, T let font = fonts::match_font(&font_request, self.scale_factor); - match (font, parley_disabled()) { + match (font, parley_disabled(), text.text()) { #[cfg(feature = "software-renderer-systemfonts")] - (fonts::Font::VectorFont(_), false) => { + (fonts::Font::VectorFont(_), false, _) => { sharedparley::draw_text(self, text, Some(self_rc), size); } #[cfg(feature = "software-renderer-systemfonts")] - (fonts::Font::VectorFont(vf), true) => { - let string = text.text(); + (fonts::Font::VectorFont(vf), true, PlainOrStyledText::Plain(string)) => { if string.trim().is_empty() { return; } @@ -2577,8 +2579,7 @@ impl crate::item_rendering::ItemRenderer for SceneBuilder<'_, T self.draw_text_paragraph(¶graph, physical_clip, offset, color, None); } - (fonts::Font::PixelFont(pf), _) => { - let string = text.text(); + (fonts::Font::PixelFont(pf), _, PlainOrStyledText::Plain(string)) => { if string.trim().is_empty() { return; } @@ -2618,6 +2619,10 @@ impl crate::item_rendering::ItemRenderer for SceneBuilder<'_, T self.draw_text_paragraph(¶graph, physical_clip, offset, color, None); } + (_, true, PlainOrStyledText::Styled(_)) + | (fonts::Font::PixelFont(_), _, PlainOrStyledText::Styled(_)) => { + panic!("Unable to get draw styled text without parley") + } } } diff --git a/internal/core/textlayout/sharedparley.rs b/internal/core/textlayout/sharedparley.rs index 095653133df..fbbe38b8424 100644 --- a/internal/core/textlayout/sharedparley.rs +++ b/internal/core/textlayout/sharedparley.rs @@ -13,6 +13,7 @@ use std::cell::RefCell; use crate::{ Color, SharedString, graphics::FontRequest, + item_rendering::PlainOrStyledText, items::TextStrokeStyle, lengths::{ LogicalBorderRadius, LogicalLength, LogicalPoint, LogicalRect, LogicalSize, PhysicalPx, @@ -225,9 +226,11 @@ impl LayoutWithoutLineBreaksBuilder { &self, text: &str, selection: Option<(Range, Color)>, - formatting: impl IntoIterator, + formatting: impl IntoIterator, link_color: Option, ) -> parley::Layout { + use crate::api::Style; + CONTEXTS.with_borrow_mut(|contexts| { let mut builder = self.ranged_builder(contexts.as_mut(), text); @@ -303,22 +306,16 @@ impl LayoutWithoutLineBreaksBuilder { } } -enum Text<'a> { - PlainText(&'a str), - #[cfg(feature = "experimental-rich-text")] - RichText(RichText<'a>), -} - fn create_text_paragraphs( layout_builder: &LayoutWithoutLineBreaksBuilder, - text: Text, + text: PlainOrStyledText, selection: Option<(Range, Color)>, link_color: Color, ) -> Vec { let paragraph_from_text = |text: &str, range: std::ops::Range, - formatting: Vec, + formatting: Vec, links: Vec<(std::ops::Range, std::string::String)>| { let selection = selection.clone().and_then(|(selection, selection_color)| { let sel_start = selection.start.max(range.start); @@ -341,7 +338,7 @@ fn create_text_paragraphs( let mut paragraphs = Vec::with_capacity(1); match text { - Text::PlainText(text) => { + PlainOrStyledText::Plain(ref text) => { let paragraph_ranges = core::iter::from_fn({ let mut start = 0; let mut char_it = text.char_indices().peekable(); @@ -372,18 +369,16 @@ fn create_text_paragraphs( )); } } - #[cfg(feature = "experimental-rich-text")] - Text::RichText(rich_text) => { + #[cfg_attr(not(feature = "experimental-rich-text"), allow(unused))] + PlainOrStyledText::Styled(rich_text) => + { + #[cfg(feature = "experimental-rich-text")] for paragraph in rich_text.paragraphs { paragraphs.push(paragraph_from_text( ¶graph.text, 0..0, paragraph.formatting, - paragraph - .links - .into_iter() - .map(|(range, link)| (range, link.into_string())) - .collect(), + paragraph.links, )); } } @@ -838,513 +833,6 @@ impl Layout { } } -#[cfg_attr(not(feature = "experimental-rich-text"), allow(unused))] -#[derive(Debug, PartialEq)] -enum Style { - Emphasis, - Strong, - Strikethrough, - Code, - Link, - Underline, - Color(Color), -} - -#[derive(Debug, PartialEq)] -struct FormattedSpan { - range: Range, - style: Style, -} - -#[cfg(feature = "experimental-rich-text")] -#[derive(Debug)] -enum ListItemType { - Ordered(u64), - Unordered, -} - -#[cfg(feature = "experimental-rich-text")] -#[derive(Debug, PartialEq)] -struct RichTextParagraph<'a> { - text: std::string::String, - formatting: Vec, - #[cfg(feature = "experimental-rich-text")] - links: std::vec::Vec<(Range, pulldown_cmark::CowStr<'a>)>, - #[cfg(not(feature = "experimental-rich-text"))] - _phantom: std::marker::PhantomData<&'a ()>, -} - -#[cfg(feature = "experimental-rich-text")] -#[derive(Debug, Default)] -struct RichText<'a> { - paragraphs: Vec>, -} - -#[cfg(feature = "experimental-rich-text")] -impl<'a> RichText<'a> { - fn begin_paragraph(&mut self, indentation: u32, list_item_type: Option) { - let mut text = std::string::String::with_capacity(indentation as usize * 4); - for _ in 0..indentation { - text.push_str(" "); - } - match list_item_type { - Some(ListItemType::Unordered) => { - if indentation % 3 == 0 { - text.push_str("• ") - } else if indentation % 3 == 1 { - text.push_str("◦ ") - } else { - text.push_str("▪ ") - } - } - Some(ListItemType::Ordered(num)) => text.push_str(&std::format!("{}. ", num)), - None => {} - }; - self.paragraphs.push(RichTextParagraph { - text, - formatting: Default::default(), - links: Default::default(), - }); - } -} - -#[cfg(feature = "experimental-rich-text")] -#[derive(Debug, thiserror::Error)] -enum RichTextError<'a> { - #[error("Spans are unbalanced: stack already empty when popped")] - Pop, - #[error("Spans are unbalanced: stack contained items at end of function")] - NotEmpty, - #[error("Paragraph not started")] - ParagraphNotStarted, - #[error("Unimplemented: {:?}", .0)] - UnimplementedTag(pulldown_cmark::Tag<'a>), - #[error("Unimplemented: {:?}", .0)] - UnimplementedEvent(pulldown_cmark::Event<'a>), - #[error("Unimplemented: {}", .0)] - UnimplementedHtmlEvent(std::string::String), - #[error("Unimplemented html tag: {}", .0)] - UnimplementedHtmlTag(std::string::String), - #[error("Unexpected {} attribute in html {}", .0, .1)] - UnexpectedAttribute(std::string::String, std::string::String), - #[error("Missing color attribute in html {}", .0)] - MissingColor(std::string::String), - #[error("Closing html tag doesn't match the opening tag. Expected {}, got {}", .0, .1)] - ClosingTagMismatch(&'a str, std::string::String), -} - -#[cfg(feature = "experimental-rich-text")] -fn parse_markdown(string: &str) -> Result, RichTextError<'_>> { - let parser = - pulldown_cmark::Parser::new_ext(string, pulldown_cmark::Options::ENABLE_STRIKETHROUGH); - - let mut rich_text = RichText::default(); - let mut list_state_stack: std::vec::Vec> = std::vec::Vec::new(); - let mut style_stack = std::vec::Vec::new(); - let mut current_url = None; - - for event in parser { - let indentation = list_state_stack.len().saturating_sub(1) as _; - - match event { - pulldown_cmark::Event::SoftBreak | pulldown_cmark::Event::HardBreak => { - rich_text.begin_paragraph(indentation, None); - } - pulldown_cmark::Event::End(pulldown_cmark::TagEnd::List(_)) => { - if list_state_stack.pop().is_none() { - return Err(RichTextError::Pop); - } - } - pulldown_cmark::Event::End( - pulldown_cmark::TagEnd::Paragraph | pulldown_cmark::TagEnd::Item, - ) => {} - pulldown_cmark::Event::Start(tag) => { - let style = match tag { - pulldown_cmark::Tag::Paragraph => { - rich_text.begin_paragraph(indentation, None); - continue; - } - pulldown_cmark::Tag::Item => { - rich_text.begin_paragraph( - indentation, - Some(match list_state_stack.last().copied() { - Some(Some(index)) => ListItemType::Ordered(index), - _ => ListItemType::Unordered, - }), - ); - if let Some(state) = list_state_stack.last_mut() { - *state = state.map(|state| state + 1); - } - continue; - } - pulldown_cmark::Tag::List(index) => { - list_state_stack.push(index); - continue; - } - pulldown_cmark::Tag::Strong => Style::Strong, - pulldown_cmark::Tag::Emphasis => Style::Emphasis, - pulldown_cmark::Tag::Strikethrough => Style::Strikethrough, - pulldown_cmark::Tag::Link { dest_url, .. } => { - current_url = Some(dest_url); - Style::Link - } - - pulldown_cmark::Tag::Heading { .. } - | pulldown_cmark::Tag::Image { .. } - | pulldown_cmark::Tag::DefinitionList - | pulldown_cmark::Tag::DefinitionListTitle - | pulldown_cmark::Tag::DefinitionListDefinition - | pulldown_cmark::Tag::TableHead - | pulldown_cmark::Tag::TableRow - | pulldown_cmark::Tag::TableCell - | pulldown_cmark::Tag::HtmlBlock - | pulldown_cmark::Tag::Superscript - | pulldown_cmark::Tag::Subscript - | pulldown_cmark::Tag::Table(_) - | pulldown_cmark::Tag::MetadataBlock(_) - | pulldown_cmark::Tag::BlockQuote(_) - | pulldown_cmark::Tag::CodeBlock(_) - | pulldown_cmark::Tag::FootnoteDefinition(_) => { - return Err(RichTextError::UnimplementedTag(tag)); - } - }; - - style_stack.push(( - style, - rich_text - .paragraphs - .last() - .ok_or(RichTextError::ParagraphNotStarted)? - .text - .len(), - )); - } - pulldown_cmark::Event::Text(text) => { - rich_text - .paragraphs - .last_mut() - .ok_or(RichTextError::ParagraphNotStarted)? - .text - .push_str(&text); - } - pulldown_cmark::Event::End(_) => { - let (style, start) = if let Some(value) = style_stack.pop() { - value - } else { - return Err(RichTextError::Pop); - }; - - let paragraph = - rich_text.paragraphs.last_mut().ok_or(RichTextError::ParagraphNotStarted)?; - let end = paragraph.text.len(); - - if let Some(url) = current_url.take() { - paragraph.links.push((start..end, url)); - } - - paragraph.formatting.push(FormattedSpan { range: start..end, style }); - } - pulldown_cmark::Event::Code(text) => { - let paragraph = - rich_text.paragraphs.last_mut().ok_or(RichTextError::ParagraphNotStarted)?; - let start = paragraph.text.len(); - paragraph.text.push_str(&text); - paragraph - .formatting - .push(FormattedSpan { range: start..paragraph.text.len(), style: Style::Code }); - } - pulldown_cmark::Event::InlineHtml(html) => { - if html.starts_with(" "", - Style::Underline => "", - other => std::unreachable!( - "Got unexpected closing style {:?} with html {}. This error should have been caught earlier.", - other, - html - ), - }; - - if (&*html) != expected_tag { - return Err(RichTextError::ClosingTagMismatch( - expected_tag, - (&*html).into(), - )); - } - - let paragraph = rich_text - .paragraphs - .last_mut() - .ok_or(RichTextError::ParagraphNotStarted)?; - let end = paragraph.text.len(); - paragraph.formatting.push(FormattedSpan { range: start..end, style }); - } else { - let mut expecting_color_attribute = false; - - for token in htmlparser::Tokenizer::from(&*html) { - match token { - Ok(htmlparser::Token::ElementStart { local: tag_type, .. }) => { - match &*tag_type { - "u" => { - style_stack.push(( - Style::Underline, - rich_text - .paragraphs - .last() - .ok_or(RichTextError::ParagraphNotStarted)? - .text - .len(), - )); - } - "font" => { - expecting_color_attribute = true; - } - _ => { - return Err(RichTextError::UnimplementedHtmlTag( - (&*tag_type).into(), - )); - } - } - } - Ok(htmlparser::Token::Attribute { - local: key, - value: Some(value), - .. - }) => match &*key { - "color" => { - if !expecting_color_attribute { - return Err(RichTextError::UnexpectedAttribute( - (&*key).into(), - (&*html).into(), - )); - } - expecting_color_attribute = false; - - let value = - i_slint_common::color_parsing::parse_color_literal(&*value) - .or_else(|| { - i_slint_common::color_parsing::named_colors() - .get(&*value) - .copied() - }) - .expect("invalid color value"); - - style_stack.push(( - Style::Color(Color::from_argb_encoded(value)), - rich_text - .paragraphs - .last() - .ok_or(RichTextError::ParagraphNotStarted)? - .text - .len(), - )); - } - _ => { - return Err(RichTextError::UnexpectedAttribute( - (&*key).into(), - (&*html).into(), - )); - } - }, - Ok(htmlparser::Token::ElementEnd { .. }) => {} - _ => { - return Err(RichTextError::UnimplementedHtmlEvent(std::format!( - "{:?}", token - ))); - } - } - } - - if expecting_color_attribute { - return Err(RichTextError::MissingColor((&*html).into())); - } - } - } - pulldown_cmark::Event::Rule - | pulldown_cmark::Event::TaskListMarker(_) - | pulldown_cmark::Event::FootnoteReference(_) - | pulldown_cmark::Event::InlineMath(_) - | pulldown_cmark::Event::DisplayMath(_) - | pulldown_cmark::Event::Html(_) => { - return Err(RichTextError::UnimplementedEvent(event)); - } - } - } - - if !style_stack.is_empty() { - return Err(RichTextError::NotEmpty); - } - - Ok(rich_text) -} - -#[cfg(feature = "experimental-rich-text")] -#[test] -fn markdown_parsing() { - assert_eq!( - parse_markdown("hello *world*").unwrap().paragraphs, - [RichTextParagraph { - text: "hello world".into(), - formatting: std::vec![FormattedSpan { range: 6..11, style: Style::Emphasis }], - links: std::vec![] - }] - ); - - assert_eq!( - parse_markdown( - " -- line 1 -- line 2 - " - ) - .unwrap() - .paragraphs, - [ - RichTextParagraph { - text: "• line 1".into(), - formatting: std::vec![], - links: std::vec![] - }, - RichTextParagraph { - text: "• line 2".into(), - formatting: std::vec![], - links: std::vec![] - } - ] - ); - - assert_eq!( - parse_markdown( - " -1. a -2. b -4. c - " - ) - .unwrap() - .paragraphs, - [ - RichTextParagraph { text: "1. a".into(), formatting: std::vec![], links: std::vec![] }, - RichTextParagraph { text: "2. b".into(), formatting: std::vec![], links: std::vec![] }, - RichTextParagraph { text: "3. c".into(), formatting: std::vec![], links: std::vec![] } - ] - ); - - assert_eq!( - parse_markdown( - " -Normal _italic_ **strong** ~~strikethrough~~ `code` -new *line* -" - ) - .unwrap() - .paragraphs, - [ - RichTextParagraph { - text: "Normal italic strong strikethrough code".into(), - formatting: std::vec![ - FormattedSpan { range: 7..13, style: Style::Emphasis }, - FormattedSpan { range: 14..20, style: Style::Strong }, - FormattedSpan { range: 21..34, style: Style::Strikethrough }, - FormattedSpan { range: 35..39, style: Style::Code } - ], - links: std::vec![] - }, - RichTextParagraph { - text: "new line".into(), - formatting: std::vec![FormattedSpan { range: 4..8, style: Style::Emphasis },], - links: std::vec![] - } - ] - ); - - assert_eq!( - parse_markdown( - " -- root - - child - - grandchild - - great grandchild -" - ) - .unwrap() - .paragraphs, - [ - RichTextParagraph { - text: "• root".into(), - formatting: std::vec![], - links: std::vec![] - }, - RichTextParagraph { - text: " ◦ child".into(), - formatting: std::vec![], - links: std::vec![] - }, - RichTextParagraph { - text: " ▪ grandchild".into(), - formatting: std::vec![], - links: std::vec![] - }, - RichTextParagraph { - text: " • great grandchild".into(), - formatting: std::vec![], - links: std::vec![] - }, - ] - ); - - assert_eq!( - parse_markdown("hello [*world*](https://example.com)").unwrap().paragraphs, - [RichTextParagraph { - text: "hello world".into(), - formatting: std::vec![ - FormattedSpan { range: 6..11, style: Style::Emphasis }, - FormattedSpan { range: 6..11, style: Style::Link } - ], - links: std::vec![(6..11, pulldown_cmark::CowStr::Borrowed("https://example.com"))] - }] - ); - - assert_eq!( - parse_markdown("hello world").unwrap().paragraphs, - [RichTextParagraph { - text: "hello world".into(), - formatting: std::vec![FormattedSpan { range: 0..11, style: Style::Underline },], - links: std::vec![] - }] - ); - - assert_eq!( - parse_markdown(r#"hello world"#).unwrap().paragraphs, - [RichTextParagraph { - text: "hello world".into(), - formatting: std::vec![FormattedSpan { - range: 0..11, - style: Style::Color(Color::from_rgb_u8(0, 0, 255)) - },], - links: std::vec![] - }] - ); - - assert_eq!( - parse_markdown(r#"hello world"#).unwrap().paragraphs, - [RichTextParagraph { - text: "hello world".into(), - formatting: std::vec![ - FormattedSpan { range: 0..11, style: Style::Color(Color::from_rgb_u8(255, 0, 0)) }, - FormattedSpan { range: 0..11, style: Style::Underline }, - ], - links: std::vec![] - }] - ); -} - pub fn draw_text( item_renderer: &mut impl GlyphRenderer, text: Pin<&dyn crate::item_rendering::RenderText>, @@ -1390,26 +878,8 @@ pub fn draw_text( scale_factor, ); - let str = text.text(); - - #[cfg(feature = "experimental-rich-text")] - let layout_text = if text.is_markdown() { - Text::RichText(match parse_markdown(&str) { - Ok(rich_text) => rich_text, - Err(error) => { - crate::debug_log!("{}", error); - return; - } - }) - } else { - Text::PlainText(&str) - }; - - #[cfg(not(feature = "experimental-rich-text"))] - let layout_text = Text::PlainText(&str); - let paragraphs_without_linebreaks = - create_text_paragraphs(&layout_builder, layout_text, None, text.link_color()); + create_text_paragraphs(&layout_builder, text.text(), None, Default::default()); let (horizontal_align, vertical_align) = text.alignment(); let text_overflow = text.overflow(); @@ -1470,14 +940,7 @@ pub fn link_under_cursor( scale_factor, ); - let str = text.text(); - let layout_text = Text::RichText(match parse_markdown(&str) { - Ok(rich_text) => rich_text, - Err(error) => { - crate::debug_log!("{}", error); - return None; - } - }); + let layout_text = text.text(); let paragraphs_without_linebreaks = create_text_paragraphs(&layout_builder, layout_text, None, text.link_color()); @@ -1574,7 +1037,7 @@ pub fn draw_text_input( let paragraphs_without_linebreaks = create_text_paragraphs( &layout_builder, - Text::PlainText(&text), + PlainOrStyledText::Plain(text), selection_and_color, Color::default(), ); @@ -1641,12 +1104,8 @@ pub fn text_size( let text = text_item.text(); - let paragraphs_without_linebreaks = create_text_paragraphs( - &layout_builder, - Text::PlainText(text.as_str()), - None, - Color::default(), - ); + let paragraphs_without_linebreaks = + create_text_paragraphs(&layout_builder, text, None, Color::default()); let layout = layout( &layout_builder, @@ -1742,8 +1201,12 @@ pub fn text_input_byte_offset_for_position( ); let text = text_input.text(); - let paragraphs_without_linebreaks = - create_text_paragraphs(&layout_builder, Text::PlainText(&text), None, Color::default()); + let paragraphs_without_linebreaks = create_text_paragraphs( + &layout_builder, + PlainOrStyledText::Plain(text), + None, + Color::default(), + ); let layout = layout( &layout_builder, @@ -1783,8 +1246,12 @@ pub fn text_input_cursor_rect_for_byte_offset( } let text = text_input.text(); - let paragraphs_without_linebreaks = - create_text_paragraphs(&layout_builder, Text::PlainText(&text), None, Color::default()); + let paragraphs_without_linebreaks = create_text_paragraphs( + &layout_builder, + PlainOrStyledText::Plain(text), + None, + Color::default(), + ); let layout = layout( &layout_builder, diff --git a/internal/interpreter/dynamic_item_tree.rs b/internal/interpreter/dynamic_item_tree.rs index 4d1ec8d3d8b..4c358ecf81a 100644 --- a/internal/interpreter/dynamic_item_tree.rs +++ b/internal/interpreter/dynamic_item_tree.rs @@ -986,7 +986,7 @@ fn generate_rtti() -> HashMap<&'static str, Rc> { rtti_for::(), rtti_for::(), rtti_for::(), - rtti_for::(), + rtti_for::(), rtti_for::(), rtti_for::(), rtti_for::(), diff --git a/internal/interpreter/eval.rs b/internal/interpreter/eval.rs index 7fce3fbed60..c78fa0f1297 100644 --- a/internal/interpreter/eval.rs +++ b/internal/interpreter/eval.rs @@ -1417,6 +1417,16 @@ fn call_builtin_function( corelib::open_url(&url); Value::Void } + BuiltinFunction::EscapeMarkdown => { + let text: SharedString = + eval_expression(&arguments[0], local_context).try_into().unwrap(); + Value::String(corelib::escape_markdown(&text).into()) + } + BuiltinFunction::ParseMarkdown => { + let text: SharedString = + eval_expression(&arguments[0], local_context).try_into().unwrap(); + Value::StyledText(corelib::parse_markdown(&text)) + } } }