add markdown widget

This commit is contained in:
Piotr 2024-06-15 13:36:25 +02:00
parent 80b7049584
commit 433d64a254
9 changed files with 840 additions and 0 deletions

349
src/widget/markdown/mod.rs Normal file
View file

@ -0,0 +1,349 @@
pub mod parser;
use std::sync::{Mutex, OnceLock};
use cosmic_text::{
Attrs, Buffer, Color, Edit, Editor, Family, FontSystem, Metrics, Shaping, SwashCache, Weight,
};
use iced::{Length, Rectangle, Size};
use iced_core::{
image, layout,
widget::{self, tree},
Widget,
};
use crate::{Element, Renderer};
// fn syntax_theme() -> &'static str {
// if cosmic::theme::is_dark() {
// return "COSMIC Dark";
// }
// "COSMIC Light"
// }
static FONT_SYSTEM: OnceLock<Mutex<FontSystem>> = OnceLock::new();
pub struct Markdown {
syntax_editor: Mutex<Editor<'static>>,
swash_cache: Mutex<SwashCache>,
margin: f32,
}
impl Markdown {
pub fn new(content: String) -> Self {
FONT_SYSTEM.get_or_init(|| Mutex::new(FontSystem::new()));
let metrics = metrics(14.0);
let buffer = Buffer::new_empty(metrics);
let mut editor = Editor::new(buffer);
editor.with_buffer_mut(|buffer| set_buffer_text(&content, buffer));
Self {
syntax_editor: Mutex::new(editor),
swash_cache: Mutex::new(SwashCache::new()),
margin: 0.0,
}
}
pub fn margin(&mut self, margin: f32) {
self.margin = margin;
}
}
pub struct State {
handle_opt: Mutex<Option<image::Handle>>,
}
impl State {
/// Creates a new [`State`].
pub fn new() -> State {
State {
handle_opt: Mutex::new(None),
}
}
}
impl<Message> Widget<Message, crate::Theme, Renderer> for Markdown {
fn size(&self) -> Size<Length> {
Size {
width: Length::Shrink,
height: Length::Shrink,
}
}
fn state(&self) -> tree::State {
tree::State::new(State::new())
}
fn layout(
&self,
_tree: &mut widget::Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let mut font_system = FONT_SYSTEM.get().unwrap().lock().unwrap();
let max_width = limits.max().width - self.margin;
let mut editor = self.syntax_editor.lock().unwrap();
editor.borrow_with(&mut font_system).shape_as_needed(true);
editor.with_buffer_mut(|buffer| {
let mut layout_lines = 0;
let mut width = 0.0;
let mut height = 0.0;
buffer.set_size(
&mut font_system,
Some(max_width),
Some(buffer.metrics().line_height),
);
buffer.set_wrap(&mut font_system, cosmic_text::Wrap::Word);
for line in buffer.lines.iter() {
match line.layout_opt() {
Some(layout) => {
layout_lines += 1;
for l in layout.iter() {
if layout_lines > 1 {
width = max_width;
break;
}
width = l.w;
}
for l in layout.iter() {
if let Some(line_height) = l.line_height_opt {
height += line_height;
} else {
height += buffer.metrics().line_height;
}
}
}
None => (),
}
}
buffer.set_size(&mut font_system, Some(max_width), Some(height));
let size = Size::new(width, height);
layout::Node::new(size)
})
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
_theme: &crate::Theme,
style: &iced_core::renderer::Style,
layout: iced_core::Layout<'_>,
_cursor: iced_core::mouse::Cursor,
_viewport: &Rectangle,
) {
let state = tree.state.downcast_ref::<State>();
let mut swash_cache = self.swash_cache.lock().unwrap();
let mut font_system = FONT_SYSTEM.get().unwrap().lock().unwrap();
let mut editor = self.syntax_editor.lock().unwrap();
let scale_factor = style.scale_factor as f32;
let view_w = layout.bounds().width as i32;
let view_h = layout.bounds().height as i32;
let calculate_image_scaled = |view: i32| -> (i32, f32) {
// Get smallest set of physical pixels that fit inside the logical pixels
let image = ((view as f32) * scale_factor).floor() as i32;
// Convert that back into logical pixels
let scaled = (image as f32) / scale_factor;
(image, scaled)
};
let calculate_ideal = |view_start: i32| -> (i32, f32) {
// Search for a perfect match within 16 pixels
for i in 0..16 {
let view = view_start - i;
let (image, scaled) = calculate_image_scaled(view);
if view == scaled as i32 {
return (image, scaled);
}
}
let (image, scaled) = calculate_image_scaled(view_start);
(image, scaled)
};
let (image_w, _scaled_w) = calculate_ideal(view_w);
let (image_h, _scaled_h) = calculate_ideal(view_h);
editor.shape_as_needed(&mut font_system, true);
let mut pixels_u8 = vec![0; image_w as usize * image_h as usize * 4];
let pixels = unsafe {
std::slice::from_raw_parts_mut(pixels_u8.as_mut_ptr() as *mut u32, pixels_u8.len() / 4)
};
let mut handle_opt = state.handle_opt.lock().unwrap();
if editor.redraw() || handle_opt.is_none() {
editor.with_buffer(|buffer| {
buffer.draw(
&mut font_system,
&mut swash_cache,
cosmic_text::Color(0xFFFFFF),
|x, y, w, h, color| {
draw_rect(
pixels,
Canvas {
w: image_w,
h: image_h,
},
Canvas {
w: w as i32,
h: h as i32,
},
Offset { x, y },
color,
);
},
);
});
}
*handle_opt = Some(image::Handle::from_pixels(
image_w as u32,
image_h as u32,
pixels_u8,
));
if let Some(ref handle) = *handle_opt {
image::Renderer::draw(
renderer,
handle.clone(),
image::FilterMethod::Nearest,
Rectangle {
x: layout.position().x,
y: layout.position().y,
width: image_w as f32,
height: image_h as f32,
},
[0.0; 4],
);
}
}
}
impl<'a, Message> From<Markdown> for Element<'a, Message> {
fn from(value: Markdown) -> Self {
Self::new(value)
}
}
struct Canvas {
w: i32,
h: i32,
}
struct Offset {
x: i32,
y: i32,
}
fn draw_rect(
buffer: &mut [u32],
canvas: Canvas,
offset: Canvas,
screen: Offset,
cosmic_color: cosmic_text::Color,
) {
// Grab alpha channel and green channel
let mut color = cosmic_color.0 & 0xFF00FF00;
// Shift red channel
color |= (cosmic_color.0 & 0x00FF0000) >> 16;
// Shift blue channel
color |= (cosmic_color.0 & 0x000000FF) << 16;
let alpha = (color >> 24) & 0xFF;
match alpha {
0 => {
// Do not draw if alpha is zero.
}
255 => {
// Handle overwrite
for x in screen.x..screen.x + offset.w {
if x < 0 || x >= canvas.w {
// Skip if y out of bounds
continue;
}
for y in screen.y..screen.y + offset.h {
if y < 0 || y >= canvas.h {
// Skip if x out of bounds
continue;
}
let line_offset = y as usize * canvas.w as usize;
let offset = line_offset + x as usize;
buffer[offset] = color;
}
}
}
_ => {
let n_alpha = 255 - alpha;
for y in screen.y..screen.y + offset.h {
if y < 0 || y >= canvas.h {
// Skip if y out of bounds
continue;
}
let line_offset = y as usize * canvas.w as usize;
for x in screen.x..screen.x + offset.w {
if x < 0 || x >= canvas.w {
// Skip if x out of bounds
continue;
}
// Alpha blend with current value
let offset = line_offset + x as usize;
let current = buffer[offset];
if current & 0xFF000000 == 0 {
// Overwrite if buffer empty
buffer[offset] = color;
} else {
let rb = ((n_alpha * (current & 0x00FF00FF))
+ (alpha * (color & 0x00FF00FF)))
>> 8;
let ag = (n_alpha * ((current & 0xFF00FF00) >> 8))
+ (alpha * (0x01000000 | ((color & 0x0000FF00) >> 8)));
buffer[offset] = (rb & 0x00FF00FF) | (ag & 0xFF00FF00);
}
}
}
}
}
}
fn metrics(font_size: f32) -> Metrics {
let line_height = (font_size * 1.4).ceil();
Metrics::new(font_size, line_height)
}
fn set_buffer_text<'a>(text: &'a str, buffer: &mut Buffer) {
let attrs = Attrs::new();
attrs.family(Family::SansSerif);
let spans: Vec<(&'a str, Attrs)> = parser::markdown_to_string(text, attrs);
buffer.set_rich_text(
&mut FONT_SYSTEM.get().unwrap().lock().unwrap(),
spans.iter().copied(),
attrs,
Shaping::Advanced,
)
}

View file

@ -0,0 +1,297 @@
use std::io;
use cosmic_text::{
Attrs, Buffer, Color, Edit, Editor, Family, FontSystem, Metrics, Shaping, Weight,
};
use markdown::{tokenize, Block, ListItem, Span};
use syntect::{
easy::HighlightLines,
highlighting::{Style, Theme, ThemeSet},
parsing::SyntaxSet,
util::LinesWithEndings,
};
use super::metrics;
pub fn markdown_to_string<'a, 'b>(content: &'a str, attrs: Attrs<'b>) -> Vec<(&'a str, Attrs<'b>)>
where
'b: 'a,
{
let mut result: Vec<(&'a str, Attrs)> = Vec::new();
for block in tokenize(content) {
result.extend(parse_block(block, attrs));
result.push(("\n", attrs));
}
result
}
fn parse_block<'a, 'b>(block: Block, attrs: Attrs<'b>) -> Vec<(&'a str, Attrs<'b>)>
where
'b: 'a,
{
let mut result: Vec<(&'a str, Attrs)> = Vec::new();
match block {
Block::Header(span, level) => {
for item in span {
match level {
1 => {
let attrs = attrs.metrics(metrics(24.0));
attrs.weight(Weight::BOLD);
result.extend(parse_span(item, attrs));
}
2 => {
let attrs = attrs.metrics(metrics(22.0));
attrs.weight(Weight::BOLD);
result.extend(parse_span(item, attrs));
}
3 => {
let attrs = attrs.metrics(metrics(20.0));
attrs.weight(Weight::BOLD);
result.extend(parse_span(item, attrs));
}
4 => {
let attrs = attrs.metrics(metrics(18.0));
attrs.weight(Weight::BOLD);
result.extend(parse_span(item, attrs));
}
5 => {
let attrs = attrs.metrics(metrics(16.0));
attrs.weight(Weight::BOLD);
result.extend(parse_span(item, attrs));
}
6 => {
let attrs = attrs.metrics(metrics(14.0));
attrs.weight(Weight::BOLD);
result.extend(parse_span(item, attrs));
}
_ => result.extend(parse_span(item, attrs)),
}
}
result.push(("\n", attrs));
}
Block::Paragraph(span) => {
for item in span {
result.extend(parse_span(item, attrs));
}
result.push(("\n", attrs));
}
Block::Blockquote(blockquote) => {
for item in blockquote {
let attrs = attrs.family(Family::Monospace);
result.extend(parse_block(item, attrs));
}
result.push(("\n", attrs));
}
Block::CodeBlock(lang, code) => {
if let Some(lang) = lang {
let code_block = highlight_code(
Box::leak(code.into_boxed_str()),
language_to_extension(lang),
attrs,
);
result.extend(code_block);
} else {
result.push((Box::leak(code.into_boxed_str()), attrs));
};
result.push(("\n", attrs));
}
Block::OrderedList(listitem, _type) => {
for (num, item) in listitem.iter().enumerate() {
let attrs = attrs.family(Family::Serif);
result.push((Box::leak(format!("{}. ", num + 1).into_boxed_str()), attrs));
result.extend(parse_listitem(item.to_owned(), attrs));
result.push(("\n", attrs));
}
result.push(("\n", attrs));
}
Block::UnorderedList(listitem) => {
for item in listitem {
let attrs = attrs.family(Family::Serif);
result.push((" - ", attrs));
result.extend(parse_listitem(item.to_owned(), attrs));
result.push(("\n", attrs));
}
result.push(("\n", attrs));
}
Block::Raw(raw_text) => {
result.push((Box::leak(raw_text.into_boxed_str()), attrs));
result.push(("\n", attrs));
}
Block::Hr => result.push(("\n", attrs)),
}
result
}
fn parse_span<'a>(span: Span, attrs: Attrs<'a>) -> Vec<(&'a str, Attrs)> {
let mut result: Vec<(&'a str, Attrs)> = Vec::new();
match span {
Span::Break => result.push(("\n", attrs)),
Span::Text(text) => result.push((Box::leak(text.into_boxed_str()), attrs)),
Span::Code(code) => {
attrs.family(Family::Monospace);
result.push((Box::leak(code.into_boxed_str()), attrs));
}
Span::Link(_, _, _) => {}
Span::Image(_, _, _) => {}
Span::Emphasis(emphasis) => {
for item in emphasis {
let attrs = attrs.family(Family::Cursive);
result.extend(parse_span(item, attrs));
}
}
Span::Strong(strong) => {
for item in strong {
let attrs = attrs.weight(Weight::BOLD);
result.extend(parse_span(item, attrs));
}
}
}
result
}
fn parse_listitem<'a>(item: ListItem, attrs: Attrs<'a>) -> Vec<(&'a str, Attrs)> {
let mut result: Vec<(&'a str, Attrs)> = Vec::new();
match item {
ListItem::Simple(simple) => {
for item in simple {
result.extend(parse_span(item, attrs));
}
}
ListItem::Paragraph(block) => {
for item in block {
result.extend(parse_block(item, attrs));
}
}
}
result
}
fn highlight_code<'a>(
code: &'a str,
extension: &str,
attrs: Attrs<'a>,
) -> Vec<(&'a str, Attrs<'a>)> {
let mut result: Vec<(&'a str, Attrs)> = Vec::new();
let ps = SyntaxSet::load_defaults_newlines();
let syntax_theme = syntax_theme();
let syntax = ps.find_syntax_by_extension(extension).unwrap();
let mut h = HighlightLines::new(syntax, &syntax_theme);
for line in LinesWithEndings::from(code) {
let ranges: Vec<(Style, &str)> = h.highlight_line(line, &ps).unwrap();
for (style, text) in ranges {
let fg = style.foreground;
let color = Color::rgb(fg.r, fg.g, fg.b);
let attrs = attrs.color(color);
result.push((text, attrs));
}
}
result
}
fn syntax_theme() -> Theme {
let ts = cosmic_syntax();
if !crate::theme::is_dark() {
return ts.themes.get("COSMIC Light").unwrap().clone();
}
ts.themes.get("COSMIC Dark").unwrap().clone()
}
fn cosmic_syntax() -> ThemeSet {
let lazy_theme_set = two_face::theme::LazyThemeSet::from(two_face::theme::extra());
let mut theme_set = syntect::highlighting::ThemeSet::from(&lazy_theme_set);
// Hardcoded COSMIC themes
for (theme_name, theme_data) in &[
("COSMIC Dark", cosmic_syntax_theme::COSMIC_DARK_TM_THEME),
("COSMIC Light", cosmic_syntax_theme::COSMIC_LIGHT_TM_THEME),
] {
let mut cursor = io::Cursor::new(theme_data);
match syntect::highlighting::ThemeSet::load_from_reader(&mut cursor) {
Ok(mut theme) => {
// Use libcosmic theme for background and gutter
theme.settings.background = Some(syntect::highlighting::Color {
r: 0,
g: 0,
b: 0,
a: 0,
});
theme.settings.gutter = Some(syntect::highlighting::Color {
r: 0,
g: 0,
b: 0,
a: 0,
});
theme_set.themes.insert(theme_name.to_string(), theme);
}
Err(err) => {
eprintln!("failed to load {:?} syntax theme: {}", theme_name, err);
}
}
}
theme_set
}
fn language_to_extension(lang: String) -> &'static str {
match lang.as_str() {
"python" => "py",
"javascript" => "js",
"java" => "java",
"c" => "c",
"cpp" => "cpp",
"c++" => "cpp",
"csharp" => "cs",
"c#" => "cs",
"php" => "php",
"ruby" => "rb",
"swift" => "swift",
"kotlin" => "kt",
"go" => "go",
"r" => "r",
"perl" => "pl",
"shell" => "sh",
"bash" => "sh",
"objective-c" => "m",
"objective-c++" => "mm",
"typescript" => "ts",
"html" => "html",
"css" => "css",
"sql" => "sql",
"matlab" => "m",
"scala" => "scala",
"rust" => "rs",
"dart" => "dart",
"elixir" => "ex",
"haskell" => "hs",
"lua" => "lua",
"assembly" => "asm",
"fortran" => "f90",
"pascal" => "pas",
"cobol" => "cob",
"erlang" => "erl",
"fsharp" => "fs",
"f#" => "fs",
"julia" => "jl",
"groovy" => "groovy",
"ada" => "adb",
"markdown" => "md",
_ => "txt",
}
}

View file

@ -247,6 +247,9 @@ pub mod list;
#[doc(inline)]
pub use list::{list_column, ListColumn};
#[cfg(feature = "markdown")]
pub mod markdown;
pub mod menu;
pub mod nav_bar;

View file

@ -90,3 +90,11 @@ pub fn monotext<'a>(text: impl Into<Cow<'a, str>> + 'a) -> Text<'a, crate::Theme
.line_height(LineHeight::Absolute(20.0.into()))
.font(crate::font::FONT_MONO_REGULAR)
}
#[cfg(feature = "markdown")]
/// [`Markdown`] widget with md text parser
pub fn markdown(content: String) -> crate::widget::markdown::Markdown {
use crate::widget::markdown;
markdown::Markdown::new(content)
}