Skip to content

Commit 5f3d9db

Browse files
authored
Add simple markdown editor example (#1513)
1 parent 2ff6318 commit 5f3d9db

File tree

5 files changed

+231
-1
lines changed

5 files changed

+231
-1
lines changed

druid/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ console_log = "0.2.0"
7777
float-cmp = { version = "0.8.0", features = ["std"], default-features = false }
7878
tempfile = "3.1.0"
7979
piet-common = { version = "=0.3.2", features = ["png"] }
80+
pulldown-cmark = { version = "0.8", default-features = false }
8081

8182
[[example]]
8283
name = "cursor"

druid/examples/markdown_preview.rs

+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright 2020 The Druid Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//! An example of live markdown preview
16+
17+
use std::ops::Range;
18+
19+
use pulldown_cmark::{Event as ParseEvent, Parser, Tag};
20+
21+
use druid::text::{Attribute, AttributeSpans, RichText};
22+
use druid::widget::prelude::*;
23+
use druid::widget::{Controller, LineBreaking, RawLabel, Scroll, Split, TextBox};
24+
use druid::{
25+
AppLauncher, Color, Data, FontFamily, FontStyle, FontWeight, Lens, LocalizedString, MenuDesc,
26+
Widget, WidgetExt, WindowDesc,
27+
};
28+
29+
const WINDOW_TITLE: LocalizedString<AppState> = LocalizedString::new("Minimal Markdown");
30+
31+
const TEXT: &str = "*Hello* ***world***! This is a `TextBox` where you can \
32+
use limited markdown notation, which is reflected in the \
33+
**styling** of the `Label` on the left.";
34+
35+
const SPACER_SIZE: f64 = 8.0;
36+
const BLOCKQUOTE_COLOR: Color = Color::grey8(0x88);
37+
const LINK_COLOR: Color = Color::rgb8(0, 0, 0xEE);
38+
39+
#[derive(Clone, Data, Lens)]
40+
struct AppState {
41+
raw: String,
42+
rendered: RichText,
43+
}
44+
45+
/// A controller that rebuilds the preview when edits occur
46+
struct RichTextRebuilder;
47+
48+
impl<W: Widget<AppState>> Controller<AppState, W> for RichTextRebuilder {
49+
fn event(
50+
&mut self,
51+
child: &mut W,
52+
ctx: &mut EventCtx,
53+
event: &Event,
54+
data: &mut AppState,
55+
env: &Env,
56+
) {
57+
let pre_data = data.raw.to_owned();
58+
child.event(ctx, event, data, env);
59+
if !data.raw.same(&pre_data) {
60+
data.rendered = rebuild_rendered_text(&data.raw);
61+
}
62+
}
63+
}
64+
65+
pub fn main() {
66+
// describe the main window
67+
let main_window = WindowDesc::new(build_root_widget)
68+
.title(WINDOW_TITLE)
69+
.menu(make_menu())
70+
.window_size((700.0, 600.0));
71+
72+
// create the initial app state
73+
let initial_state = AppState {
74+
raw: TEXT.to_owned(),
75+
rendered: rebuild_rendered_text(TEXT),
76+
};
77+
78+
// start the application
79+
AppLauncher::with_window(main_window)
80+
.use_simple_logger()
81+
.launch(initial_state)
82+
.expect("Failed to launch application");
83+
}
84+
85+
fn build_root_widget() -> impl Widget<AppState> {
86+
let label = Scroll::new(
87+
RawLabel::new()
88+
.with_text_color(Color::BLACK)
89+
.with_line_break_mode(LineBreaking::WordWrap)
90+
.lens(AppState::rendered)
91+
.expand_width()
92+
.padding((SPACER_SIZE * 4.0, SPACER_SIZE)),
93+
)
94+
.vertical()
95+
.background(Color::grey8(222))
96+
.expand();
97+
98+
let textbox = TextBox::multiline()
99+
.lens(AppState::raw)
100+
.controller(RichTextRebuilder)
101+
.expand()
102+
.padding(5.0);
103+
104+
Split::columns(label, textbox)
105+
}
106+
107+
/// Parse a markdown string and generate a `RichText` object with
108+
/// the appropriate attributes.
109+
fn rebuild_rendered_text(text: &str) -> RichText {
110+
let mut current_pos = 0;
111+
let mut buffer = String::new();
112+
let mut attrs = AttributeSpans::new();
113+
let mut tag_stack = Vec::new();
114+
115+
let parser = Parser::new(text);
116+
for event in parser {
117+
match event {
118+
ParseEvent::Start(tag) => {
119+
tag_stack.push((current_pos, tag));
120+
}
121+
ParseEvent::Text(txt) => {
122+
buffer.push_str(&txt);
123+
current_pos += txt.len();
124+
}
125+
ParseEvent::End(end_tag) => {
126+
let (start_off, tag) = tag_stack
127+
.pop()
128+
.expect("parser does not return unbalanced tags");
129+
assert_eq!(end_tag, tag, "mismatched tags?");
130+
add_attribute_for_tag(&tag, start_off..current_pos, &mut attrs);
131+
if add_newline_after_tag(&tag) {
132+
buffer.push_str("\n\n");
133+
current_pos += 2;
134+
}
135+
}
136+
ParseEvent::Code(txt) => {
137+
buffer.push_str(&txt);
138+
let range = current_pos..current_pos + txt.len();
139+
attrs.add(range, Attribute::font_family(FontFamily::MONOSPACE));
140+
current_pos += txt.len();
141+
}
142+
ParseEvent::Html(txt) => {
143+
buffer.push_str(&txt);
144+
let range = current_pos..current_pos + txt.len();
145+
attrs.add(range.clone(), Attribute::font_family(FontFamily::MONOSPACE));
146+
attrs.add(range, Attribute::text_color(BLOCKQUOTE_COLOR));
147+
current_pos += txt.len();
148+
}
149+
ParseEvent::HardBreak => {
150+
buffer.push_str("\n\n");
151+
current_pos += 1;
152+
}
153+
_ => (),
154+
}
155+
}
156+
RichText::new_with_attributes(buffer.into(), attrs)
157+
}
158+
159+
fn add_newline_after_tag(tag: &Tag) -> bool {
160+
!matches!(
161+
tag,
162+
Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link(..)
163+
)
164+
}
165+
166+
fn add_attribute_for_tag(tag: &Tag, range: Range<usize>, attrs: &mut AttributeSpans) {
167+
match tag {
168+
Tag::Heading(lvl) => {
169+
let font_size = match lvl {
170+
1 => 38.,
171+
2 => 32.0,
172+
3 => 26.0,
173+
4 => 20.0,
174+
5 => 16.0,
175+
_ => 12.0,
176+
};
177+
attrs.add(range.clone(), Attribute::size(font_size));
178+
attrs.add(range, Attribute::weight(FontWeight::BOLD));
179+
}
180+
Tag::BlockQuote => {
181+
attrs.add(range.clone(), Attribute::style(FontStyle::Italic));
182+
attrs.add(range, Attribute::text_color(BLOCKQUOTE_COLOR));
183+
}
184+
Tag::CodeBlock(_) => {
185+
attrs.add(range, Attribute::font_family(FontFamily::MONOSPACE));
186+
}
187+
Tag::Emphasis => attrs.add(range, Attribute::style(FontStyle::Italic)),
188+
Tag::Strong => attrs.add(range, Attribute::weight(FontWeight::BOLD)),
189+
Tag::Link(..) => {
190+
attrs.add(range.clone(), Attribute::underline(true));
191+
attrs.add(range, Attribute::text_color(LINK_COLOR));
192+
}
193+
// ignore other tags for now
194+
_ => (),
195+
}
196+
}
197+
198+
#[allow(unused_assignments, unused_mut)]
199+
fn make_menu<T: Data>() -> MenuDesc<T> {
200+
let mut base = MenuDesc::empty();
201+
#[cfg(target_os = "macos")]
202+
{
203+
base = base.append(druid::platform_menus::mac::application::default())
204+
}
205+
#[cfg(any(target_os = "windows", target_os = "linux"))]
206+
{
207+
base = base.append(druid::platform_menus::win::file::default());
208+
}
209+
base.append(
210+
MenuDesc::new(LocalizedString::new("common-menu-edit-menu"))
211+
.append(druid::platform_menus::common::undo())
212+
.append(druid::platform_menus::common::redo())
213+
.append_separator()
214+
.append(druid::platform_menus::common::cut().disabled())
215+
.append(druid::platform_menus::common::copy())
216+
.append(druid::platform_menus::common::paste()),
217+
)
218+
}

druid/examples/web/build.rs

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use std::{env, fs};
1919
/// Examples known to not work with the web backend are skipped.
2020
/// Ideally this list will eventually be empty.
2121
const EXCEPTIONS: &[&str] = &[
22+
"markdown_preview", // rich text not implemented in piet-web
2223
"svg", // usvg doesn't currently build as Wasm.
2324
"async_event", // the web backend doesn't currently support spawning threads.
2425
"blocking_function", // the web backend doesn't currently support spawning threads.

druid/src/text/attribute.rs

+5
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ pub enum Attribute {
9494
}
9595

9696
impl AttributeSpans {
97+
/// Create a new, empty `AttributeSpans`.
98+
pub fn new() -> Self {
99+
Default::default()
100+
}
101+
97102
/// Add a new [`Attribute`] over the provided [`Range`].
98103
pub fn add(&mut self, range: Range<usize>, attr: Attribute) {
99104
match attr {

druid/src/text/storage.rs

+6-1
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ pub struct RichText {
4747
impl RichText {
4848
/// Create a new `RichText` object with the provided text.
4949
pub fn new(buffer: ArcStr) -> Self {
50+
RichText::new_with_attributes(buffer, Default::default())
51+
}
52+
53+
/// Create a new `RichText`, providing explicit attributes.
54+
pub fn new_with_attributes(buffer: ArcStr, attributes: AttributeSpans) -> Self {
5055
RichText {
5156
buffer,
52-
attrs: Arc::new(AttributeSpans::default()),
57+
attrs: Arc::new(attributes),
5358
}
5459
}
5560

0 commit comments

Comments
 (0)