use std::path::{Path, PathBuf};
use std::time::Duration;
use gpui_kit::base::input::{InputBaseState, MultiLineMode};
use gpui_kit::base::text::TextView;
use gpui_kit::base::{ElementExt as _, POPUP_PRIORITY};
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{
Copy, Cut, Editor, EditorState, InputEvent, Paste, Redo, Replace, RopeExt as _, Search,
SelectAll, TextareaState, Undo,
};
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::text::TextViewState;
use gpui_kit::component::{
ActiveTheme as _, Sizable as _, Theme, TitleBar, h_flex, h_resizable, resizable_panel, v_flex,
window_border,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::convert;
use crate::defaults;
use crate::export;
use crate::highlight;
use crate::insert;
use crate::io::{self, Document};
use crate::keymap::{
self, ExportHtml, ExportPdf, Import, MakeDefault, Open, Quit, Save, SaveAs, ToggleEditing,
ToggleFullscreen, ToggleMenuBar, ViewRendered, ViewSource, ViewSplit,
};
use crate::rendered::{RenderedEvent, RenderedView};
use crate::settings::{Settings, ViewMode};
use crate::theme::{self, PreviewTheme};
pub const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(80);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PreviewFormat {
Markdown,
Html,
}
impl PreviewFormat {
pub fn for_path(path: &Path) -> Self {
match path.extension().and_then(|ext| ext.to_str()) {
Some(ext) if ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm") => {
Self::Html
}
_ => Self::Markdown,
}
}
}
struct InsertMenu {
menu: Entity<PopupMenu>,
position: Point<Pixels>,
_dismiss: Subscription,
}
#[derive(Clone)]
enum ActiveEditor {
Source(Entity<EditorState>),
Block(Entity<TextareaState>),
}
impl ActiveEditor {
fn read<R>(&self, cx: &App, f: impl FnOnce(&dyn LineInput) -> R) -> R {
match self {
Self::Source(editor) => f(editor.read(cx)),
Self::Block(editor) => f(editor.read(cx)),
}
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self {
Self::Source(editor) => editor.focus_handle(cx),
Self::Block(editor) => editor.focus_handle(cx),
}
}
fn cursor_line_bounds(&self, cx: &App) -> Option<Bounds<Pixels>> {
self.read(cx, |state| state.cursor_line_bounds())
}
fn cursor_line_is_blank(&self, cx: &App) -> bool {
self.read(cx, |state| state.cursor_line_trimmed().is_empty())
}
fn insert(&self, kind: insert::BlockKind, window: &mut Window, cx: &mut App) {
match self {
Self::Source(editor) => editor.update(cx, |state, cx| {
insert::insert_block(state, kind, window, cx);
}),
Self::Block(editor) => editor.update(cx, |state, cx| {
insert::insert_block(state, kind, window, cx);
}),
}
}
}
trait LineInput {
fn cursor_line_bounds(&self) -> Option<Bounds<Pixels>>;
fn cursor_line_trimmed(&self) -> String;
}
impl<M: MultiLineMode> LineInput for InputBaseState<M> {
fn cursor_line_bounds(&self) -> Option<Bounds<Pixels>> {
let line = insert::cursor_line_range(self);
self.range_to_bounds(&(line.start..line.start))
}
fn cursor_line_trimmed(&self) -> String {
insert::cursor_line_trimmed(self)
}
}
pub struct Smep {
editor: Entity<EditorState>,
preview: Entity<TextViewState>,
rendered: Entity<RenderedView>,
path: Option<PathBuf>,
origin: Option<PathBuf>,
format: PreviewFormat,
source: SharedString,
previewed: SharedString,
saved: SharedString,
preview_refresh: Option<Task<()>>,
blocks: Vec<usize>,
scroll_sync: Option<usize>,
insert_menu: Option<InsertMenu>,
settings: Settings,
editing: bool,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
fn preview_state(
format: PreviewFormat,
text: &str,
cx: &mut Context<TextViewState>,
) -> TextViewState {
match format {
PreviewFormat::Markdown => TextViewState::markdown(text, cx),
PreviewFormat::Html => TextViewState::html(text, cx),
}
}
fn top_visible_line(state: &EditorState) -> Option<usize> {
let text = state.text();
let lines = text.lines_len();
if lines == 0 {
return Some(0);
}
let top_of = |row: usize| {
let offset = text.line_start_offset(row);
state.range_to_bounds(&(offset..offset)).map(|b| b.top())
};
let first_top = top_of(0)?;
let (mut last_same, mut beyond) = (0, lines);
while last_same + 1 < beyond {
let mid = (last_same + beyond) / 2;
if top_of(mid) == Some(first_top) {
last_same = mid;
} else {
beyond = mid;
}
}
Some(last_same)
}
impl Smep {
pub fn new(
document: Document,
settings: Settings,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Theme::sync_system_appearance(Some(window), cx);
let editor = cx.new(|cx| {
EditorState::new(window, cx)
.default_value(document.text.clone())
.language(highlight::LANGUAGE)
.soft_wrap(true)
.line_number(false)
.searchable(true)
.placeholder("Write here. Type / on an empty line to insert a block.")
});
editor.update(cx, |state, cx| {
state.set_highlighter_factory(highlight::factory(), cx);
state.focus(window, cx);
});
let preview = cx.new(|cx| preview_state(document.format, &document.text, cx));
let rendered = cx.new(|cx| RenderedView::new(editor.clone(), document.format, window, cx));
let subscriptions = vec![
cx.subscribe_in(
&rendered,
window,
|this, _, event: &RenderedEvent, window, cx| match event {
RenderedEvent::SlashTyped => this.open_insert_menu(window, cx),
},
),
cx.subscribe_in(
&editor,
window,
|this, editor, event: &InputEvent, window, cx| {
if let InputEvent::Change = event {
let was_dirty = this.is_dirty();
this.source = editor.read(cx).value();
if this.is_dirty() != was_dirty {
this.refresh_title(window);
}
this.schedule_preview_refresh(window, cx);
if Self::slash_typed(editor.read(cx)) {
this.open_insert_menu(window, cx);
}
cx.notify();
}
},
),
cx.observe(&editor, |_, _, cx| cx.notify()),
cx.observe(&rendered, |_, _, cx| cx.notify()),
window.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}),
];
window.on_window_should_close(cx, {
let this = cx.weak_entity();
move |window, cx| {
let Some(smep) = this.upgrade() else {
return true;
};
if !smep.read(cx).is_dirty() {
return true;
}
let proceed = smep.update(cx, |this, cx| this.confirm_discard(window, cx));
let handle = window.window_handle();
cx.spawn(async move |cx| {
if proceed.await {
let _ = handle.update(cx, |_, window, _| window.remove_window());
}
})
.detach();
false
}
});
let source: SharedString = document.text.into();
let saved = if document.origin.is_some() {
SharedString::default()
} else {
source.clone()
};
let mut this = Self {
editor,
preview,
rendered,
path: document.path,
origin: document.origin,
format: document.format,
source: source.clone(),
previewed: source,
saved,
preview_refresh: None,
blocks: Vec::new(),
scroll_sync: None,
insert_menu: None,
focus_handle: cx.focus_handle(),
settings,
editing: false,
_subscriptions: subscriptions,
};
this.schedule_block_table(window, cx);
this.refresh_title(window);
this.rendered
.update(cx, |rendered, cx| rendered.set_read_only(true, window, cx));
this.rendered
.read(cx)
.focus_handle()
.clone()
.focus(window, cx);
this
}
#[cfg(test)]
pub fn is_editing(&self) -> bool {
self.editing
}
pub fn layout(&self) -> ViewMode {
if self.editing {
self.settings.view_mode
} else {
ViewMode::Rendered
}
}
pub fn set_editing(&mut self, editing: bool, window: &mut Window, cx: &mut Context<Self>) {
if editing {
self.set_view_mode(self.settings.view_mode, window, cx);
return;
}
if !self.editing {
return;
}
self.editing = false;
self.insert_menu = None;
self.rendered
.update(cx, |rendered, cx| rendered.set_read_only(true, window, cx));
self.rendered
.read(cx)
.focus_handle()
.clone()
.focus(window, cx);
cx.notify();
}
pub fn preview_theme(&self) -> PreviewTheme {
self.settings.preview_theme
}
pub fn set_preview_theme(&mut self, theme: PreviewTheme, cx: &mut Context<Self>) {
if self.settings.preview_theme == theme {
return;
}
self.settings.preview_theme = theme;
self.save_settings();
cx.notify();
}
pub fn view_mode(&self) -> ViewMode {
self.settings.view_mode
}
pub fn set_view_mode(&mut self, mode: ViewMode, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.view_mode != mode {
self.settings.view_mode = mode;
self.save_settings();
}
self.editing = true;
self.rendered
.update(cx, |rendered, cx| rendered.set_read_only(false, window, cx));
if mode == ViewMode::Rendered {
match self.rendered.read(cx).active_editor() {
Some(block) => block.update(cx, |state, cx| state.focus(window, cx)),
None => self
.rendered
.read(cx)
.focus_handle()
.clone()
.focus(window, cx),
}
} else {
self.editor.update(cx, |state, cx| state.focus(window, cx));
}
cx.notify();
}
pub fn menu_bar_shown(&self) -> bool {
self.settings.menu_bar
}
fn save_settings(&self) {
if let Err(err) = self.settings.save() {
eprintln!("smep: could not save settings: {err}");
}
}
fn view_source(&mut self, _: &ViewSource, window: &mut Window, cx: &mut Context<Self>) {
self.set_view_mode(ViewMode::Source, window, cx);
}
fn view_split(&mut self, _: &ViewSplit, window: &mut Window, cx: &mut Context<Self>) {
self.set_view_mode(ViewMode::Split, window, cx);
}
fn view_rendered(&mut self, _: &ViewRendered, window: &mut Window, cx: &mut Context<Self>) {
self.set_view_mode(ViewMode::Rendered, window, cx);
}
fn toggle_editing(&mut self, _: &ToggleEditing, window: &mut Window, cx: &mut Context<Self>) {
self.set_editing(!self.editing, window, cx);
}
fn toggle_menu_bar(&mut self, _: &ToggleMenuBar, _: &mut Window, cx: &mut Context<Self>) {
self.settings.menu_bar = !self.settings.menu_bar;
self.save_settings();
cx.notify();
}
fn toggle_fullscreen(
&mut self,
_: &ToggleFullscreen,
window: &mut Window,
cx: &mut Context<Self>,
) {
window.toggle_fullscreen();
cx.notify();
}
fn quit(&mut self, _: &Quit, window: &mut Window, cx: &mut Context<Self>) {
let proceed = self.confirm_discard(window, cx);
cx.spawn(async move |_, cx| {
if proceed.await {
cx.update(|cx| cx.quit());
}
})
.detach();
}
fn render_menu_bar(&self, cx: &mut Context<Self>) -> AnyElement {
let editor_focus = self.menu_focus(cx);
let mode = self.view_mode();
let editing = self.editing;
let menu_bar = self.menu_bar_shown();
let current_theme = self.preview_theme();
let this = cx.weak_entity();
let button = |id: &'static str, label: &'static str| {
Button::new(id)
.label(label)
.ghost()
.xsmall()
.tab_stop(false)
};
h_flex()
.id("menu-bar")
.h_full()
.items_center()
.gap_0p5()
.child(button("menu-file", "File").dropdown_menu({
let focus = editor_focus.clone();
move |menu, _, _| {
menu.menu("Open…", Box::new(Open))
.menu("Import as Markdown…", Box::new(Import))
.menu("Save", Box::new(Save))
.menu("Save As…", Box::new(SaveAs))
.separator()
.menu("Export as PDF…", Box::new(ExportPdf))
.menu("Export as HTML…", Box::new(ExportHtml))
.separator()
.menu("Open Markdown Files with smep", Box::new(MakeDefault))
.separator()
.menu("Quit", Box::new(Quit))
.action_context(focus.clone())
}
}))
.child(button("menu-edit", "Edit").dropdown_menu({
let focus = editor_focus.clone();
move |menu, _, _| {
menu.menu("Undo", Box::new(Undo))
.menu("Redo", Box::new(Redo))
.separator()
.menu("Cut", Box::new(Cut))
.menu("Copy", Box::new(Copy))
.menu("Paste", Box::new(Paste))
.menu("Select All", Box::new(SelectAll))
.separator()
.menu("Find", Box::new(Search))
.menu("Replace", Box::new(Replace))
.action_context(focus.clone())
}
}))
.child(button("menu-view", "View").dropdown_menu({
let focus = editor_focus;
move |mut menu, window, cx| {
menu = menu
.item(
PopupMenuItem::new("Editing")
.checked(editing)
.action(Box::new(ToggleEditing)),
)
.separator();
for candidate in [ViewMode::Source, ViewMode::Split, ViewMode::Rendered] {
let action: Box<dyn Action> = match candidate {
ViewMode::Source => Box::new(ViewSource),
ViewMode::Split => Box::new(ViewSplit),
ViewMode::Rendered => Box::new(ViewRendered),
};
menu = menu.item(
PopupMenuItem::new(candidate.label())
.checked(candidate == mode)
.action(action),
);
}
let themes = PopupMenu::build(window, cx, |mut submenu, _, _| {
for candidate in theme::ALL {
let this = this.clone();
submenu = submenu.item(
PopupMenuItem::new(candidate.label())
.checked(candidate == current_theme)
.on_click(move |_, _, cx| {
let _ = this.update(cx, |this, cx| {
this.set_preview_theme(candidate, cx)
});
}),
);
}
submenu
});
menu.separator()
.item(PopupMenuItem::submenu("Preview Theme", themes))
.separator()
.item(
PopupMenuItem::new("Menu Bar")
.checked(menu_bar)
.action(Box::new(ToggleMenuBar)),
)
.menu("Full Screen", Box::new(ToggleFullscreen))
.action_context(focus.clone())
}
}))
.into_any_element()
}
fn render_title_bar(&self, cx: &mut Context<Self>) -> AnyElement {
let title = format!(
"{}{}",
if self.is_dirty() { "● " } else { "" },
Document::display_name(self.named_path().as_deref())
);
let muted = cx.theme().muted_foreground;
let menu_bar = self.menu_bar_shown().then(|| self.render_menu_bar(cx));
let mode_button = Button::new("edit-toggle")
.label(if self.editing { "Read" } else { "Edit" })
.ghost()
.xsmall()
.tab_stop(false)
.on_click(cx.listener(|this, _, window, cx| {
this.set_editing(!this.editing, window, cx);
}));
TitleBar::new()
.child(
h_flex()
.id("chrome")
.flex_1()
.h_full()
.items_center()
.children(menu_bar)
.child(div().flex_1())
.child(div().text_sm().text_color(muted).child(title))
.child(div().flex_1())
.child(
div()
.pr_2()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(mode_button),
),
)
.into_any_element()
}
fn schedule_preview_refresh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.preview_refresh = Some(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(PREVIEW_DEBOUNCE).await;
let Ok(Some(text)) = this.update(cx, |this, _| {
(this.previewed != this.source).then(|| this.source.clone())
}) else {
return;
};
let blocks = cx
.background_spawn({
let text = text.clone();
async move { highlight::block_start_lines(&text) }
})
.await;
let _ = this.update(cx, |this, cx| this.show_preview(text, blocks, cx));
}));
}
fn show_preview(&mut self, text: SharedString, blocks: Vec<usize>, cx: &mut Context<Self>) {
self.previewed = text.clone();
self.blocks = blocks;
self.preview
.update(cx, |state, cx| state.set_text(&text, cx));
self.scroll_sync = None;
cx.notify();
}
fn set_preview(&mut self, format: PreviewFormat, window: &mut Window, cx: &mut Context<Self>) {
self.preview_refresh = None;
let text = self.source.clone();
if format != self.format {
self.format = format;
self.preview = cx.new(|cx| preview_state(format, &text, cx));
self.rendered
.update(cx, |rendered, cx| rendered.set_format(format, window, cx));
}
self.show_preview(text, Vec::new(), cx);
self.schedule_block_table(window, cx);
}
fn schedule_block_table(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let text = self.previewed.clone();
self.preview_refresh = Some(cx.spawn_in(window, async move |this, cx| {
let blocks = cx
.background_spawn({
let text = text.clone();
async move { highlight::block_start_lines(&text) }
})
.await;
let _ = this.update(cx, |this, cx| {
if this.previewed == text {
this.blocks = blocks;
this.scroll_sync = None;
cx.notify();
}
});
}));
}
fn sync_preview_scroll(&mut self, cx: &mut Context<Self>) {
let Some(top) = top_visible_line(self.editor.read(cx)) else {
return;
};
if self.scroll_sync == Some(top) {
return;
}
self.scroll_sync = Some(top);
let list = self.preview.read(cx).list_state().clone();
let count = list.item_count();
if count == 0 {
return;
}
let item_ix = if count == self.blocks.len() {
self.blocks
.partition_point(|&start| start <= top)
.saturating_sub(1)
} else {
let lines = self.editor.read(cx).text().lines_len().max(1);
(count * top / lines).min(count - 1)
};
list.scroll_to(ListOffset {
item_ix,
offset_in_item: px(0.),
});
}
pub fn title_for(path: Option<&Path>, dirty: bool) -> String {
let marker = if dirty { "● " } else { "" };
format!("{marker}{} — smep", Document::display_name(path))
}
pub fn is_dirty(&self) -> bool {
self.source != self.saved
}
fn named_path(&self) -> Option<PathBuf> {
self.path
.clone()
.or_else(|| self.origin.as_deref().map(convert::markdown_path_for))
}
fn refresh_title(&self, window: &mut Window) {
window.set_window_title(&Self::title_for(
self.named_path().as_deref(),
self.is_dirty(),
));
}
fn load(&mut self, document: Document, window: &mut Window, cx: &mut Context<Self>) {
self.path = document.path;
self.origin = document.origin;
self.source = document.text.clone().into();
self.saved = if self.origin.is_some() {
SharedString::default()
} else {
self.source.clone()
};
self.rendered
.update(cx, |rendered, cx| rendered.deactivate(window, cx));
self.editor
.update(cx, |state, cx| state.set_value(document.text, window, cx));
self.set_preview(document.format, window, cx);
self.refresh_title(window);
}
fn write_to(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> bool {
let text = self.editor.read(cx).value();
match io::write(&path, &text) {
Ok(()) => {
self.saved = text.clone();
self.source = text;
let format = PreviewFormat::for_path(&path);
self.path = Some(path);
if format != self.format {
self.set_preview(format, window, cx);
}
self.refresh_title(window);
cx.notify();
true
}
Err(err) => {
self.report(
&format!("Could not save {}", path.display()),
&err.to_string(),
window,
cx,
);
false
}
}
}
fn report(&self, what: &str, detail: &str, window: &mut Window, cx: &mut Context<Self>) {
let answered = window.prompt(PromptLevel::Critical, what, Some(detail), &["OK"], cx);
cx.spawn(async move |_, _| {
let _ = answered.await;
})
.detach();
}
fn save_task(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<bool> {
match self.path.clone() {
Some(path) => Task::ready(self.write_to(path, window, cx)),
None => self.save_as_task(window, cx),
}
}
fn save_as_task(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<bool> {
let named = self.named_path();
let directory = named
.as_deref()
.and_then(Path::parent)
.filter(|dir| !dir.as_os_str().is_empty())
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_default();
let suggested = named
.as_deref()
.map(|path| Document::display_name(Some(path)))
.unwrap_or_else(|| "untitled.md".to_string());
let chosen = cx.prompt_for_new_path(&directory, Some(&suggested));
cx.spawn_in(window, async move |this, cx| {
let Ok(Ok(Some(path))) = chosen.await else {
return false;
};
this.update_in(cx, |this, window, cx| this.write_to(path, window, cx))
.unwrap_or(false)
})
}
fn confirm_discard(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<bool> {
if !self.is_dirty() {
return Task::ready(true);
}
let detail = format!(
"{} has unsaved changes.",
Document::display_name(self.named_path().as_deref())
);
let answer = window.prompt(
PromptLevel::Warning,
"Save changes?",
Some(&detail),
&["Save", "Don't Save", "Cancel"],
cx,
);
cx.spawn_in(window, async move |this, cx| match answer.await {
Ok(0) => match this.update_in(cx, |this, window, cx| this.save_task(window, cx)) {
Ok(saved) => saved.await,
Err(_) => false,
},
Ok(1) => true,
_ => false,
})
}
fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
let proceed = self.confirm_discard(window, cx);
cx.spawn_in(window, async move |this, cx| {
if !proceed.await {
return;
}
let Ok(chosen) = this.update(cx, |_, cx| {
cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
})
}) else {
return;
};
let Ok(Ok(Some(paths))) = chosen.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
let _ = this.update_in(cx, |this, window, cx| this.load_path(path, window, cx));
})
.detach();
}
pub fn open_document_at(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let proceed = self.confirm_discard(window, cx);
cx.spawn_in(window, async move |this, cx| {
if proceed.await {
let _ = this.update_in(cx, |this, window, cx| this.load_path(path, window, cx));
}
})
.detach();
}
fn load_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
if let Some(format) = convert::Format::for_import(&path) {
self.import_path(path, format, window, cx);
return;
}
match Document::read(path.clone()) {
Ok(document) => self.load(document, window, cx),
Err(err) => self.report(
&format!("Could not open {}", path.display()),
&err.to_string(),
window,
cx,
),
}
}
fn import_path(
&mut self,
path: PathBuf,
format: convert::Format,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.spawn_in(window, async move |this, cx| {
let converted = cx
.background_spawn({
let path = path.clone();
async move { Document::import(path, format) }
})
.await;
let _ = this.update_in(cx, |this, window, cx| match converted {
Ok(document) => this.load(document, window, cx),
Err(err) => this.report(
&format!("Could not import {}", path.display()),
&format!("{err:#}"),
window,
cx,
),
});
})
.detach();
}
fn import(&mut self, _: &Import, window: &mut Window, cx: &mut Context<Self>) {
let proceed = self.confirm_discard(window, cx);
cx.spawn_in(window, async move |this, cx| {
if !proceed.await {
return;
}
let Ok(chosen) = this.update(cx, |_, cx| {
cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some("Import".into()),
})
}) else {
return;
};
let Ok(Ok(Some(paths))) = chosen.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
let _ = this.update_in(cx, |this, window, cx| {
match convert::Format::for_explicit_import(&path) {
Some(format) => this.import_path(path, format, window, cx),
None => this.report(
&format!("Cannot import {}", path.display()),
"smep converts PDF (.pdf), Word (.docx), HTML (.html) and text (.txt) files.",
window,
cx,
),
}
});
})
.detach();
}
fn ask_export_path(&self, extension: &str, cx: &mut Context<Self>) -> Task<Option<PathBuf>> {
let named = self.named_path();
let directory = named
.as_deref()
.and_then(Path::parent)
.filter(|dir| !dir.as_os_str().is_empty())
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_default();
let stem = named
.as_deref()
.and_then(Path::file_stem)
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".to_string());
let chosen = cx.prompt_for_new_path(&directory, Some(&format!("{stem}.{extension}")));
cx.background_spawn(async move {
match chosen.await {
Ok(Ok(Some(path))) => Some(path),
_ => None,
}
})
}
fn export_pdf(&mut self, _: &ExportPdf, window: &mut Window, cx: &mut Context<Self>) {
let chosen = self.ask_export_path("pdf", cx);
let text = self.editor.read(cx).value();
let base = self
.named_path()
.as_deref()
.and_then(Path::parent)
.filter(|dir| !dir.as_os_str().is_empty())
.map(Path::to_path_buf);
cx.spawn_in(window, async move |this, cx| {
let Some(path) = chosen.await else {
return;
};
let written = cx
.background_spawn({
let path = path.clone();
async move {
let pdf = export::to_pdf(&text, base.as_deref())?;
std::fs::write(&path, pdf)?;
anyhow::Ok(())
}
})
.await;
if let Err(err) = written {
let _ = this.update_in(cx, |this, window, cx| {
this.report(
&format!("Could not export {}", path.display()),
&format!("{err:#}"),
window,
cx,
)
});
}
})
.detach();
}
fn export_html(&mut self, _: &ExportHtml, window: &mut Window, cx: &mut Context<Self>) {
let chosen = self.ask_export_path("html", cx);
let text = self.editor.read(cx).value();
let title = export::first_heading(&text)
.unwrap_or_else(|| Document::display_name(self.named_path().as_deref()));
cx.spawn_in(window, async move |this, cx| {
let Some(path) = chosen.await else {
return;
};
if let Err(err) = std::fs::write(&path, export::to_html(&text, &title)) {
let _ = this.update_in(cx, |this, window, cx| {
this.report(
&format!("Could not export {}", path.display()),
&err.to_string(),
window,
cx,
)
});
}
})
.detach();
}
fn make_default(&mut self, _: &MakeDefault, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let outcome = cx
.background_spawn(async { defaults::make_default() })
.await;
let _ = this.update_in(cx, |this, window, cx| match outcome {
Ok(outcome) => {
let title = if outcome.done {
"smep is the default for Markdown"
} else {
"One step left"
};
let answered = window.prompt(
PromptLevel::Info,
title,
Some(&outcome.message),
&["OK"],
cx,
);
cx.spawn(async move |_, _| {
let _ = answered.await;
})
.detach();
}
Err(err) => this.report(
"Could not make smep the default for Markdown",
&format!("{err:#}"),
window,
cx,
),
});
})
.detach();
}
fn on_drop(&mut self, paths: &ExternalPaths, window: &mut Window, cx: &mut Context<Self>) {
if let Some(path) = paths.paths().first() {
self.open_document_at(path.clone(), window, cx);
}
}
fn save(&mut self, _: &Save, window: &mut Window, cx: &mut Context<Self>) {
self.save_task(window, cx).detach();
}
fn save_as(&mut self, _: &SaveAs, window: &mut Window, cx: &mut Context<Self>) {
self.save_as_task(window, cx).detach();
}
fn slash_typed(state: &EditorState) -> bool {
insert::cursor_line_trimmed(state) == "/"
&& state.cursor() == insert::cursor_line_range(state).end
}
fn active_editor(&self, cx: &App) -> ActiveEditor {
if self.layout() == ViewMode::Rendered
&& let Some(editor) = self.rendered.read(cx).active_editor()
{
return ActiveEditor::Block(editor);
}
ActiveEditor::Source(self.editor.clone())
}
fn cursor_line_bounds(&self, cx: &App) -> Option<Bounds<Pixels>> {
self.active_editor(cx).cursor_line_bounds(cx)
}
fn menu_focus(&self, cx: &App) -> FocusHandle {
match self.active_editor(cx) {
ActiveEditor::Source(_) if self.layout() == ViewMode::Rendered => {
self.rendered.read(cx).focus_handle().clone()
}
editor => editor.focus_handle(cx),
}
}
fn open_menu(
&mut self,
position: Point<Pixels>,
build: impl FnOnce(PopupMenu) -> PopupMenu,
window: &mut Window,
cx: &mut Context<Self>,
) {
let editor_focus = self.menu_focus(cx);
let menu = PopupMenu::build(window, cx, |menu, _, _| {
build(menu).action_context(editor_focus)
});
let dismiss = cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, _, cx| {
this.insert_menu = None;
cx.notify();
});
self.insert_menu = Some(InsertMenu {
menu,
position,
_dismiss: dismiss,
});
cx.notify();
}
fn insert_items(editor: &ActiveEditor, mut menu: PopupMenu) -> PopupMenu {
for entry in insert::MENU {
menu = match entry {
Some(kind) => {
let kind = *kind;
let editor = editor.clone();
menu.item(
PopupMenuItem::new(kind.label())
.on_click(move |_, window, cx| editor.insert(kind, window, cx)),
)
}
None => menu.separator(),
};
}
menu
}
pub fn open_insert_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.insert_menu.is_some() {
return;
}
let Some(bounds) = self.cursor_line_bounds(cx) else {
return;
};
let editor = self.active_editor(cx);
self.open_menu(
bounds.bottom_left(),
move |menu| Self::insert_items(&editor, menu),
window,
cx,
);
}
pub fn open_context_menu(
&mut self,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let editor = self.active_editor(cx);
self.open_menu(
position,
move |menu| {
let menu = menu
.menu("Cut", Box::new(Cut))
.menu("Copy", Box::new(Copy))
.menu("Paste", Box::new(Paste))
.separator();
Self::insert_items(&editor, menu)
},
window,
cx,
);
}
pub fn open_theme_menu(
&mut self,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let current = self.preview_theme();
let this = cx.weak_entity();
self.open_menu(
position,
move |mut menu| {
for theme in theme::ALL {
let this = this.clone();
menu = menu.item(
PopupMenuItem::new(theme.label())
.checked(theme == current)
.on_click(move |_, _, cx| {
let _ =
this.update(cx, |this, cx| this.set_preview_theme(theme, cx));
}),
);
}
menu
},
window,
cx,
);
}
fn on_editor_mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if event.button != MouseButton::Left || !self.is_below_text(event.position, cx) {
return;
}
let this = cx.entity();
window.defer(cx, move |window, cx| {
this.update(cx, |this, cx| {
this.editor.update(cx, |state, cx| {
let end = state.text().len();
state.set_selected_range(end..end, cx);
});
this.open_insert_menu(window, cx);
});
});
}
fn is_below_text(&self, position: Point<Pixels>, cx: &App) -> bool {
let state = self.editor.read(cx);
if !state.input_bounds().contains(&position) {
return false;
}
let len = state.text().len();
match state.range_to_bounds(&(len..len)) {
Some(last) => position.y > last.bottom(),
None => false,
}
}
fn render_plus(&self, window: &Window, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.insert_menu.is_some() {
return None;
}
let editor = self.active_editor(cx);
if !editor.focus_handle(cx).is_focused(window) || !editor.cursor_line_is_blank(cx) {
return None;
}
let bounds = editor.cursor_line_bounds(cx)?;
let size = px(18.);
let origin = point(
bounds.left() - px(24.),
bounds.top() + (bounds.size.height - size) / 2.,
);
let border = cx.theme().border;
let color = cx.theme().muted_foreground;
let hover = cx.theme().muted;
let plus = div()
.id("insert-plus")
.size(size)
.flex()
.items_center()
.justify_center()
.rounded_full()
.border_1()
.border_color(border)
.text_color(color)
.text_sm()
.cursor_pointer()
.hover(move |style| style.bg(hover))
.child("+")
.on_click(cx.listener(|this, _, window, cx| this.open_insert_menu(window, cx)));
Some(
deferred(anchored().position(origin).child(plus))
.with_priority(1)
.into_any_element(),
)
}
fn render_insert_menu(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let open = self.insert_menu.as_ref()?;
let focus = open.menu.focus_handle(cx);
if !focus.contains_focused(window, cx) {
focus.focus(window, cx);
}
Some(
deferred(
anchored()
.position(open.position)
.anchor(Anchor::TopLeft)
.snap_to_window_with_margin(px(8.))
.child(open.menu.clone()),
)
.with_priority(POPUP_PRIORITY)
.into_any_element(),
)
}
}
impl Render for Smep {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let follow_editor = {
let this = cx.weak_entity();
move |_, _: &mut Window, cx: &mut App| {
let _ = this.update(cx, |this, cx| this.sync_preview_scroll(cx));
}
};
let palette = self.preview_theme().palette();
let background = palette.map_or(cx.theme().background, |p| p.background);
let foreground = palette.map_or(cx.theme().foreground, |p| p.foreground);
let preview = div()
.id("preview")
.size_full()
.overflow_hidden()
.bg(background)
.text_color(foreground)
.when_some(palette.and_then(|p| p.font_family()), |this, family| {
this.font_family(family)
})
.px_6()
.py_4()
.on_mouse_down(
MouseButton::Right,
cx.listener(|this, event: &MouseDownEvent, window, cx| {
this.open_theme_menu(event.position, window, cx);
}),
)
.on_prepaint(follow_editor)
.child(
TextView::new(&self.preview)
.scrollable(true)
.when_some(palette, |view, p| view.style(p.text_view_style())),
);
let context_menu = {
let this = cx.weak_entity();
move |menu, window: &mut Window, cx: &mut App| {
let position = window.mouse_position();
let this = this.clone();
window.defer(cx, move |window, cx| {
let _ =
this.update(cx, |this, cx| this.open_context_menu(position, window, cx));
});
menu
}
};
let editor = div()
.id("editor")
.size_full()
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_editor_mouse_down))
.child(
Editor::new(&self.editor)
.bordered(false)
.size_full()
.pl(px(28.))
.context_menu(context_menu),
);
let plus = self.render_plus(window, cx);
let menu = self.render_insert_menu(window, cx);
let title_bar = self.render_title_bar(cx);
let content = match self.layout() {
ViewMode::Source => editor.into_any_element(),
ViewMode::Rendered => {
self.rendered
.update(cx, |rendered, cx| rendered.set_palette(palette, cx));
div()
.id("rendered-pane")
.size_full()
.on_mouse_down(
MouseButton::Right,
cx.listener(|this, event: &MouseDownEvent, window, cx| {
this.open_theme_menu(event.position, window, cx);
}),
)
.child(self.rendered.clone())
.into_any_element()
}
ViewMode::Split => h_resizable("smep-split")
.child(resizable_panel().child(editor.into_any_element()))
.child(resizable_panel().child(preview.into_any_element()))
.into_any_element(),
};
let drop_highlight = cx.theme().ring;
window_border().child(
v_flex()
.size_full()
.key_context(keymap::CONTEXT)
.track_focus(&self.focus_handle)
.drag_over::<ExternalPaths>(move |style, _, _, _| {
style.border_2().border_color(drop_highlight)
})
.on_drop(cx.listener(Self::on_drop))
.on_action(cx.listener(Self::open))
.on_action(cx.listener(Self::import))
.on_action(cx.listener(Self::export_pdf))
.on_action(cx.listener(Self::export_html))
.on_action(cx.listener(Self::make_default))
.on_action(cx.listener(Self::save))
.on_action(cx.listener(Self::save_as))
.on_action(cx.listener(Self::quit))
.on_action(cx.listener(Self::view_source))
.on_action(cx.listener(Self::view_split))
.on_action(cx.listener(Self::view_rendered))
.on_action(cx.listener(Self::toggle_editing))
.on_action(cx.listener(Self::toggle_menu_bar))
.on_action(cx.listener(Self::toggle_fullscreen))
.child(title_bar)
.child(div().flex_1().min_h_0().w_full().child(content))
.children(plus)
.children(menu),
)
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use gpui_kit::base::input::{InputBaseState, InputModeKind};
use gpui_kit::component::input::{EditorState, Position, TextareaState};
use gpui_kit::{
Entity, Focusable as _, MouseButton, MouseDownEvent, TestAppContext, VisualTestContext,
point, px,
};
use super::{PREVIEW_DEBOUNCE, PreviewFormat, Smep};
use crate::insert::{self, BlockKind};
use crate::io::Document;
use crate::keymap::{Open, Save};
use crate::settings::{Settings, ViewMode};
use crate::theme::PreviewTheme;
fn primary(keys: &str) -> String {
format!(
"{}-{keys}",
if cfg!(target_os = "macos") {
"cmd"
} else {
"ctrl"
}
)
}
fn open<'a>(
cx: &'a mut TestAppContext,
text: &str,
) -> (Entity<Smep>, &'a mut VisualTestContext) {
open_document(
cx,
Document {
path: None,
text: text.to_string(),
format: PreviewFormat::Markdown,
origin: None,
},
)
}
fn open_document(
cx: &mut TestAppContext,
document: Document,
) -> (Entity<Smep>, &mut VisualTestContext) {
open_with(cx, document, Settings::default())
}
fn open_with(
cx: &mut TestAppContext,
document: Document,
settings: Settings,
) -> (Entity<Smep>, &mut VisualTestContext) {
let (smep, cx) = open_reading(cx, document, settings);
cx.update(|window, cx| {
smep.update(cx, |this, cx| this.set_editing(true, window, cx));
});
(smep, cx)
}
fn open_reading(
cx: &mut TestAppContext,
document: Document,
settings: Settings,
) -> (Entity<Smep>, &mut VisualTestContext) {
cx.update(|cx| {
gpui_kit::init(cx);
crate::theme::install(cx);
crate::keymap::init(cx);
});
cx.add_window_view(|window, cx| Smep::new(document, settings, window, cx))
}
fn mouse_down(cx: &mut VisualTestContext, button: MouseButton, x: f32, y: f32) {
cx.simulate_event(MouseDownEvent {
button,
position: point(px(x), px(y)),
modifiers: Default::default(),
click_count: 1,
first_mouse: false,
});
cx.run_until_parked();
}
fn editor(smep: &Entity<Smep>, cx: &VisualTestContext) -> Entity<EditorState> {
cx.read(|cx| smep.read(cx).editor.clone())
}
fn draw(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, cx| {
_ = window.draw(cx);
});
}
fn value<M: InputModeKind>(
editor: &Entity<InputBaseState<M>>,
cx: &VisualTestContext,
) -> String {
cx.read(|cx| editor.read(cx).value().to_string())
}
fn menu_open(smep: &Entity<Smep>, cx: &VisualTestContext) -> bool {
cx.read(|cx| smep.read(cx).insert_menu.is_some())
}
fn dirty(smep: &Entity<Smep>, cx: &VisualTestContext) -> bool {
cx.read(|cx| smep.read(cx).is_dirty())
}
fn type_text<M: InputModeKind>(
editor: &Entity<InputBaseState<M>>,
text: &str,
cx: &mut VisualTestContext,
) {
cx.update(|window, cx| {
editor.update(cx, |state, cx| state.replace_all(text, window, cx));
});
}
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("smep-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[gpui_kit::test]
fn an_edit_in_the_editor_reaches_the_preview(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
type_text(&editor, "# Hello", cx);
assert_eq!(cx.read(|cx| smep.read(cx).source.to_string()), "# Hello");
}
#[gpui_kit::test]
fn the_preview_follows_after_a_pause_in_typing(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
type_text(&editor, "# One", cx);
type_text(&editor, "# One two", cx);
assert_eq!(cx.read(|cx| smep.read(cx).previewed.to_string()), "");
cx.executor().advance_clock(PREVIEW_DEBOUNCE);
cx.run_until_parked();
assert_eq!(
cx.read(|cx| smep.read(cx).previewed.to_string()),
"# One two"
);
}
#[gpui_kit::test]
fn scrolling_the_editor_scrolls_the_preview(cx: &mut TestAppContext) {
let text: String = (1..=400).map(|n| format!("Paragraph {n}\n\n")).collect();
let (smep, cx) = open(cx, &text);
let editor = editor(&smep, cx);
draw(cx);
let list = cx.read(|cx| smep.read(cx).preview.read(cx).list_state().clone());
assert_eq!(list.logical_scroll_top().item_ix, 0);
assert!(list.item_count() > 100, "one item per paragraph");
cx.update(|_, cx| {
editor.update(cx, |state, cx| {
state.set_scroll_offset(gpui_kit::point(gpui_kit::px(0.), gpui_kit::px(-5000.)), cx)
});
});
draw(cx);
draw(cx);
let display_row = cx.read(|cx| editor.read(cx).visible_row_range().unwrap().start);
let top_line = cx.read(|cx| smep.read(cx).scroll_sync);
let top_line = top_line.expect("the editor has laid out");
assert!(top_line > 100, "editor top line {top_line}");
assert_eq!(top_line, display_row);
let item = list.logical_scroll_top().item_ix;
assert_eq!(item, top_line / 2, "preview item for line {top_line}");
}
#[gpui_kit::test]
fn the_initial_text_is_the_preview_source(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "- one\n- two");
let editor = editor(&smep, cx);
assert_eq!(
cx.read(|cx| smep.read(cx).source.to_string()),
"- one\n- two"
);
assert_eq!(value(&editor, cx), "- one\n- two");
assert!(!dirty(&smep, cx));
}
#[gpui_kit::test]
fn editing_marks_the_document_dirty_and_saving_clears_it(cx: &mut TestAppContext) {
let dir = TempDir::new("save");
let path = dir.path("notes.md");
std::fs::write(&path, "before").unwrap();
let (smep, cx) = open_document(cx, Document::read(path.clone()).unwrap());
let editor = editor(&smep, cx);
assert!(!dirty(&smep, cx));
type_text(&editor, "after\r\n", cx);
assert!(dirty(&smep, cx));
assert_eq!(
cx.read(|cx| Smep::title_for(smep.read(cx).path.as_deref(), true)),
"● notes.md — smep"
);
cx.update(|window, cx| smep.update(cx, |this, cx| this.save(&Save, window, cx)));
cx.run_until_parked();
assert!(!dirty(&smep, cx));
assert_eq!(std::fs::read(&path).unwrap(), b"after\r\n");
type_text(&editor, "before", cx);
assert!(dirty(&smep, cx));
}
#[gpui_kit::test]
async fn saving_an_untitled_document_asks_for_a_path(cx: &mut TestAppContext) {
let dir = TempDir::new("save-as");
let target = dir.path("chosen.md");
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
type_text(&editor, "fresh", cx);
let saved = cx.update(|window, cx| smep.update(cx, |this, cx| this.save_task(window, cx)));
cx.simulate_new_path_selection({
let target = target.clone();
move |_| Some(target)
});
assert!(saved.await);
assert_eq!(std::fs::read_to_string(&target).unwrap(), "fresh");
assert!(!dirty(&smep, cx));
assert_eq!(cx.read(|cx| smep.read(cx).path.clone()), Some(target));
}
#[gpui_kit::test]
async fn cancelling_save_as_leaves_the_document_dirty(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
type_text(&editor, "fresh", cx);
let saved = cx.update(|window, cx| smep.update(cx, |this, cx| this.save_task(window, cx)));
cx.simulate_new_path_selection(|_| None);
assert!(!saved.await);
assert!(dirty(&smep, cx));
assert_eq!(cx.read(|cx| smep.read(cx).path.clone()), None);
}
#[gpui_kit::test]
async fn discarding_asks_only_while_dirty(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
let clean =
cx.update(|window, cx| smep.update(cx, |this, cx| this.confirm_discard(window, cx)));
assert!(clean.await, "a clean document needs no prompt");
assert!(!cx.has_pending_prompt());
type_text(&editor, "unsaved", cx);
let proceed =
cx.update(|window, cx| smep.update(cx, |this, cx| this.confirm_discard(window, cx)));
assert!(cx.has_pending_prompt());
cx.simulate_prompt_answer("Cancel");
assert!(!proceed.await, "cancel keeps the document");
let proceed =
cx.update(|window, cx| smep.update(cx, |this, cx| this.confirm_discard(window, cx)));
cx.simulate_prompt_answer("Don't Save");
assert!(proceed.await, "don't save proceeds without writing");
assert!(dirty(&smep, cx));
}
#[gpui_kit::test]
fn open_replaces_the_buffer_and_resets_dirty(cx: &mut TestAppContext) {
let dir = TempDir::new("open");
let path = dir.path("other.html");
std::fs::write(&path, "<b>bold</b>").unwrap();
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
cx.update(|window, cx| smep.update(cx, |this, cx| this.open(&Open, window, cx)));
cx.run_until_parked();
assert!(cx.did_prompt_for_paths());
cx.simulate_path_prompt_response({
let path = path.clone();
move |_| Some(vec![path])
});
cx.run_until_parked();
assert_eq!(value(&editor, cx), "<b>bold</b>");
assert_eq!(
cx.read(|cx| smep.read(cx).previewed.to_string()),
"<b>bold</b>",
"open refreshes the preview at once, no debounce"
);
assert_eq!(
cx.read(|cx| smep.read(cx).source.to_string()),
"<b>bold</b>"
);
assert_eq!(cx.read(|cx| smep.read(cx).format), PreviewFormat::Html);
assert_eq!(cx.read(|cx| smep.read(cx).path.clone()), Some(path));
assert!(!dirty(&smep, cx));
}
#[gpui_kit::test]
fn view_modes_switch_by_keyboard_and_are_remembered(cx: &mut TestAppContext) {
let dir = TempDir::new("view-mode");
let settings = Settings {
path: Some(dir.path("settings.toml")),
..Settings::default()
};
let document = Document {
path: None,
text: "# T".into(),
format: PreviewFormat::Markdown,
origin: None,
};
let (smep, cx) = open_with(cx, document, settings);
let editor = editor(&smep, cx);
cx.update(|window, cx| editor.update(cx, |state, cx| state.focus(window, cx)));
draw(cx);
assert_eq!(cx.read(|cx| smep.read(cx).view_mode()), ViewMode::Split);
cx.simulate_keystrokes(&primary("1"));
cx.run_until_parked();
assert_eq!(cx.read(|cx| smep.read(cx).view_mode()), ViewMode::Source);
assert!(
std::fs::read_to_string(dir.path("settings.toml"))
.unwrap()
.contains("view_mode = \"source\"")
);
cx.simulate_keystrokes(&primary("3"));
cx.run_until_parked();
draw(cx);
assert_eq!(cx.read(|cx| smep.read(cx).view_mode()), ViewMode::Rendered);
cx.simulate_keystrokes(&primary("2"));
cx.run_until_parked();
assert_eq!(cx.read(|cx| smep.read(cx).view_mode()), ViewMode::Split);
}
#[gpui_kit::test]
fn opens_reading_and_a_key_or_the_view_menu_starts_editing(cx: &mut TestAppContext) {
let dir = TempDir::new("reading");
let settings = Settings {
path: Some(dir.path("settings.toml")),
..Settings::default()
};
let document = Document {
path: None,
text: "# Title\n\npara".into(),
format: PreviewFormat::Markdown,
origin: None,
};
let (smep, cx) = open_reading(cx, document, settings);
draw(cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
let reading = |cx: &VisualTestContext| {
cx.read(|cx| {
let this = smep.read(cx);
(
!this.is_editing(),
this.layout(),
this.rendered.read(cx).is_read_only(),
)
})
};
assert_eq!(reading(cx), (true, ViewMode::Rendered, true));
assert_eq!(cx.read(|cx| smep.read(cx).view_mode()), ViewMode::Split);
assert!(
cx.update(|window, cx| rendered.read(cx).focus_handle().is_focused(window)),
"the rendered view holds the focus while reading"
);
activate_block(&smep, 9..13, cx);
assert!(cx.read(|cx| rendered.read(cx).active_editor()).is_none());
cx.simulate_keystrokes(&primary("e"));
cx.run_until_parked();
draw(cx);
assert_eq!(reading(cx), (false, ViewMode::Split, false));
let source = editor(&smep, cx);
assert!(
cx.update(|window, cx| source.read(cx).focus_handle(cx).is_focused(window)),
"the source editor takes the focus for editing"
);
rendered_mode(&smep, cx);
activate_block(&smep, 9..13, cx);
assert!(cx.read(|cx| rendered.read(cx).active_editor()).is_some());
cx.simulate_keystrokes(&primary("e"));
cx.run_until_parked();
draw(cx);
assert_eq!(reading(cx), (true, ViewMode::Rendered, true));
assert!(cx.read(|cx| rendered.read(cx).active_editor()).is_none());
cx.simulate_keystrokes(&primary("1"));
cx.run_until_parked();
assert_eq!(reading(cx), (false, ViewMode::Source, false));
let saved = std::fs::read_to_string(dir.path("settings.toml")).unwrap();
assert!(saved.contains("view_mode = \"source\""), "{saved}");
assert!(!saved.contains("editing"), "{saved}");
}
#[gpui_kit::test]
fn an_html_document_is_one_html_block_in_the_rendered_view(cx: &mut TestAppContext) {
let dir = TempDir::new("html-rendered");
let page = dir.path("page.html");
let html = "<h1>Title</h1>\n<p>Some <b>bold</b> text.</p>\n\n<ul><li>one</li></ul>\n";
std::fs::write(&page, html).unwrap();
let document = Document::read(page.clone()).unwrap();
let settings = Settings::default();
let (smep, cx) = open_reading(cx, document, settings);
draw(cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
assert_eq!(
cx.read(|cx| rendered.read(cx).format()),
PreviewFormat::Html
);
assert_eq!(
cx.read(|cx| rendered.read(cx).block_ranges()),
vec![0..html.trim_end().len()]
);
assert!(
crate::highlight::block_ranges(html).len() > 1,
"Markdown would split it"
);
let notes = dir.path("notes.md");
std::fs::write(¬es, "# T\n\npara\n").unwrap();
cx.update(|window, cx| {
smep.update(cx, |this, cx| this.load_path(notes.clone(), window, cx))
});
draw(cx);
assert_eq!(
cx.read(|cx| rendered.read(cx).format()),
PreviewFormat::Markdown
);
assert_eq!(
cx.read(|cx| rendered.read(cx).block_ranges()),
vec![0..3, 5..9]
);
rendered_mode(&smep, cx);
activate_block(&smep, 5..9, cx);
let saved =
cx.update(|window, cx| smep.update(cx, |this, cx| this.save_as_task(window, cx)));
let target = dir.path("notes.html");
cx.simulate_new_path_selection({
let target = target.clone();
move |_| Some(target)
});
cx.run_until_parked();
assert!(cx.read(|cx| rendered.read(cx).active_editor()).is_none());
assert_eq!(
cx.read(|cx| rendered.read(cx).format()),
PreviewFormat::Html
);
assert_eq!(cx.read(|cx| rendered.read(cx).block_ranges()), vec![0..9]);
drop(saved);
}
#[gpui_kit::test]
fn keys_scroll_the_rendered_view_while_reading(cx: &mut TestAppContext) {
let text: String = (1..=200).map(|n| format!("Paragraph {n}\n\n")).collect();
let document = Document {
path: None,
text,
format: PreviewFormat::Markdown,
origin: None,
};
let (smep, cx) = open_reading(cx, document, Settings::default());
draw(cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
let scroll =
|cx: &VisualTestContext| cx.read(|cx| rendered.read(cx).scroll_handle().clone());
let offset = |cx: &mut VisualTestContext| {
draw(cx);
f32::from(scroll(cx).offset().y)
};
let viewport = f32::from(scroll(cx).bounds().size.height);
let max = f32::from(scroll(cx).max_offset().y);
assert!(
max > 2. * viewport,
"the document must overflow: {max} vs {viewport}"
);
assert_eq!(offset(cx), 0.);
cx.simulate_keystrokes("pagedown");
let after_page = offset(cx);
assert_eq!(after_page, -(viewport - 40.), "a page less a line");
cx.simulate_keystrokes("space");
assert_eq!(offset(cx), 2. * after_page);
cx.simulate_keystrokes("shift-space pageup");
assert_eq!(offset(cx), 0.);
cx.simulate_keystrokes("down down up");
assert_eq!(offset(cx), -40.);
cx.simulate_keystrokes("end");
assert_eq!(offset(cx), -max);
cx.simulate_keystrokes("pagedown");
assert_eq!(offset(cx), -max, "clamped at the end");
cx.simulate_keystrokes("home");
assert_eq!(offset(cx), 0.);
cx.simulate_keystrokes("up");
assert_eq!(offset(cx), 0., "clamped at the top");
rendered_mode(&smep, cx);
activate_block(&smep, 0..11, cx);
let block = active_block(&smep, cx);
cx.simulate_keystrokes("space end");
cx.run_until_parked();
assert_eq!(value(&block, cx), "Paragraph 1 ");
assert_eq!(offset(cx), 0.);
}
#[gpui_kit::test]
fn right_click_on_the_rendered_view_opens_the_theme_menu(cx: &mut TestAppContext) {
let document = Document {
path: None,
text: "# T\n\npara".into(),
format: PreviewFormat::Markdown,
origin: None,
};
let (smep, cx) = open_reading(cx, document, Settings::default());
draw(cx);
assert_eq!(
cx.read(|cx| smep.read(cx).preview_theme()),
PreviewTheme::System
);
mouse_down(cx, MouseButton::Right, 300., 300.);
assert!(
menu_open(&smep, cx),
"the theme menu opens over the document"
);
draw(cx);
cx.simulate_keystrokes("down down enter");
cx.run_until_parked();
assert_eq!(
cx.read(|cx| smep.read(cx).preview_theme()),
PreviewTheme::Paper
);
assert!(!menu_open(&smep, cx));
rendered_mode(&smep, cx);
activate_block(&smep, 5..9, cx);
let block = active_block(&smep, cx);
draw(cx);
let bounds = cx.read(|cx| block.read(cx).input_bounds());
mouse_down(
cx,
MouseButton::Right,
f32::from(bounds.left()) + 4.,
f32::from(bounds.top()) + 4.,
);
assert!(!menu_open(&smep, cx), "no theme menu over the block");
}
fn rendered_mode(smep: &Entity<Smep>, cx: &mut VisualTestContext) {
cx.update(|window, cx| {
smep.update(cx, |this, cx| {
this.set_view_mode(ViewMode::Rendered, window, cx)
})
});
draw(cx);
}
fn activate_block(
smep: &Entity<Smep>,
range: std::ops::Range<usize>,
cx: &mut VisualTestContext,
) {
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
cx.update(|window, cx| {
rendered.update(cx, |rendered, cx| rendered.activate(range, window, cx))
});
draw(cx);
}
fn active_block(smep: &Entity<Smep>, cx: &VisualTestContext) -> Entity<TextareaState> {
cx.read(|cx| smep.read(cx).rendered.read(cx).active_editor())
.expect("a block is being edited")
}
#[gpui_kit::test]
fn editing_a_block_in_the_rendered_view_writes_through(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title\n\npara\n\n- x");
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
activate_block(&smep, 9..13, cx);
let block = active_block(&smep, cx);
assert_eq!(value(&block, cx), "para");
assert_eq!(
cx.read(|cx| block.read(cx).cursor()),
4,
"cursor at the block's end"
);
type_text(&block, "para two", cx);
assert_eq!(value(&document, cx), "# Title\n\npara two\n\n- x");
assert!(dirty(&smep, cx));
type_text(&block, "para two\n\nthree", cx);
assert_eq!(value(&document, cx), "# Title\n\npara two\n\nthree\n\n- x");
activate_block(&smep, 0..7, cx);
let block = active_block(&smep, cx);
assert_eq!(value(&block, cx), "# Title");
type_text(&block, "# Big Title", cx);
assert_eq!(
value(&document, cx),
"# Big Title\n\npara two\n\nthree\n\n- x"
);
}
#[gpui_kit::test]
fn a_new_block_at_the_end_gets_its_blank_line(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title");
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
cx.update(|window, cx| {
rendered.update(cx, |rendered, cx| rendered.activate_at_end(window, cx))
});
draw(cx);
let block = active_block(&smep, cx);
type_text(&block, "new", cx);
assert_eq!(value(&document, cx), "# Title\n\nnew");
type_text(&block, "new text", cx);
assert_eq!(
value(&document, cx),
"# Title\n\nnew text",
"the gap is added once"
);
let (smep, cx) = open(cx, "a\r\nb");
let document = cx.read(|cx| smep.read(cx).editor.clone());
rendered_mode(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
cx.update(|window, cx| {
rendered.update(cx, |rendered, cx| rendered.activate_at_end(window, cx))
});
draw(cx);
let block = active_block(&smep, cx);
type_text(&block, "c", cx);
assert_eq!(value(&document, cx), "a\r\nb\r\n\r\nc");
}
#[gpui_kit::test]
fn a_slash_in_a_block_opens_the_menu_and_inserts_there(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title");
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
cx.update(|window, cx| {
rendered.update(cx, |rendered, cx| rendered.activate_at_end(window, cx))
});
draw(cx);
cx.simulate_input("/");
assert!(menu_open(&smep, cx), "the slash in a block opens the menu");
draw(cx);
cx.simulate_keystrokes("down down enter");
cx.run_until_parked();
let block = active_block(&smep, cx);
assert_eq!(value(&block, cx), "## ");
assert_eq!(value(&document, cx), "# Title\n\n## ");
assert!(!menu_open(&smep, cx));
}
#[gpui_kit::test]
fn saving_from_a_block_editor_writes_the_whole_document(cx: &mut TestAppContext) {
let dir = TempDir::new("block-save");
let path = dir.path("blocks.md");
std::fs::write(&path, "# Title\n\npara\n").unwrap();
let (smep, cx) = open_document(cx, Document::read(path.clone()).unwrap());
rendered_mode(&smep, cx);
activate_block(&smep, 9..13, cx);
let block = active_block(&smep, cx);
assert!(cx.update(|window, cx| block.focus_handle(cx).is_focused(window)));
type_text(&block, "para (edited)", cx);
assert!(dirty(&smep, cx));
cx.simulate_keystrokes(&primary("s"));
cx.run_until_parked();
assert!(!dirty(&smep, cx));
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"# Title\n\npara (edited)\n"
);
}
#[gpui_kit::test]
fn a_click_below_the_blocks_starts_a_focused_block_at_the_end(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title");
rendered_mode(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
mouse_down(cx, MouseButton::Left, 300., 500.);
draw(cx);
let block = active_block(&smep, cx);
assert!(
cx.update(|window, cx| block.focus_handle(cx).is_focused(window)),
"the new block keeps focus after the view's own click handling"
);
assert!(!cx.update(|window, cx| rendered.read(cx).focus_handle().is_focused(window)));
type_text(&block, "tail", cx);
assert_eq!(value(&editor(&smep, cx), cx), "# Title\n\ntail");
}
#[gpui_kit::test]
async fn importing_a_file_makes_a_new_unsaved_markdown_document(cx: &mut TestAppContext) {
let dir = TempDir::new("import");
let source = dir.path("notes.txt");
std::fs::write(&source, "plain notes\r\n\r\n\r\nmore\r\n").unwrap();
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
smep.update(cx, |this, cx| this.load_path(source.clone(), window, cx))
});
cx.run_until_parked();
assert_eq!(value(&editor, cx), "plain notes\n\nmore\n");
assert!(dirty(&smep, cx), "converted text is not on disk yet");
assert_eq!(cx.read(|cx| smep.read(cx).path.clone()), None);
assert_eq!(
cx.read(|cx| Smep::title_for(smep.read(cx).named_path().as_deref(), true)),
"● notes.md — smep"
);
let saved = cx.update(|window, cx| smep.update(cx, |this, cx| this.save_task(window, cx)));
let target = dir.path("notes.md");
cx.simulate_new_path_selection({
let target = target.clone();
let expected_dir = dir.0.clone();
move |directory| {
assert_eq!(
directory, expected_dir,
"the dialog opens next to the original"
);
Some(target)
}
});
assert!(saved.await);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"plain notes\n\nmore\n"
);
assert!(!dirty(&smep, cx));
assert_eq!(cx.read(|cx| smep.read(cx).path.clone()), Some(target));
}
#[gpui_kit::test]
fn a_dropped_file_opens_after_the_unsaved_changes_prompt(cx: &mut TestAppContext) {
use gpui_kit::{ExternalPaths, FileDropEvent};
let dir = TempDir::new("drop");
let dropped = dir.path("dropped.md");
std::fs::write(&dropped, "# Dropped").unwrap();
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
type_text(&editor, "unsaved", cx);
draw(cx);
let position = point(px(300.), px(300.));
let paths = ExternalPaths([dropped.clone()].into_iter().collect());
cx.simulate_event(FileDropEvent::Entered { position, paths });
cx.simulate_event(FileDropEvent::Submit { position });
cx.run_until_parked();
assert!(cx.has_pending_prompt(), "the unsaved edit is asked about");
cx.simulate_prompt_answer("Don't Save");
cx.run_until_parked();
assert_eq!(value(&editor, cx), "# Dropped");
assert_eq!(cx.read(|cx| smep.read(cx).path.clone()), Some(dropped));
}
#[gpui_kit::test]
fn importing_something_unreadable_reports_instead_of_loading(cx: &mut TestAppContext) {
let dir = TempDir::new("import-bad");
let bad = dir.path("scan.pdf");
std::fs::write(&bad, b"not a pdf").unwrap();
let (smep, cx) = open(cx, "kept");
let editor = editor(&smep, cx);
cx.update(|window, cx| smep.update(cx, |this, cx| this.load_path(bad, window, cx)));
cx.run_until_parked();
assert!(cx.has_pending_prompt(), "an error dialog is shown");
cx.simulate_prompt_answer("OK");
assert_eq!(value(&editor, cx), "kept");
assert!(!dirty(&smep, cx));
}
#[cfg(all(feature = "pdf-export", feature = "pdf-import"))]
#[gpui_kit::test]
fn exporting_writes_a_pdf_and_an_html_page_next_to_the_document(cx: &mut TestAppContext) {
use crate::keymap::ExportHtml;
let dir = TempDir::new("export");
let path = dir.path("report.md");
std::fs::write(&path, "# Report\n\nBody *text*.\n").unwrap();
let (smep, cx) = open_document(cx, Document::read(path.clone()).unwrap());
let editor = editor(&smep, cx);
cx.update(|window, cx| editor.update(cx, |state, cx| state.focus(window, cx)));
draw(cx);
cx.simulate_keystrokes(&primary("shift-e"));
let pdf_path = dir.path("out.pdf");
cx.simulate_new_path_selection({
let pdf_path = pdf_path.clone();
let expected_dir = dir.0.clone();
move |directory| {
assert_eq!(directory, expected_dir);
Some(pdf_path)
}
});
cx.run_until_parked();
let pdf = std::fs::read(&pdf_path).expect("the PDF was written");
assert!(pdf.starts_with(b"%PDF-"));
let text = crate::convert::from_bytes(&pdf, crate::convert::Format::Pdf).unwrap();
assert!(text.contains("Report") && text.contains("Body"), "{text:?}");
cx.update(|window, cx| {
smep.update(cx, |this, cx| this.export_html(&ExportHtml, window, cx))
});
let html_path = dir.path("out.html");
cx.simulate_new_path_selection({
let html_path = html_path.clone();
move |_| Some(html_path)
});
cx.run_until_parked();
let html = std::fs::read_to_string(&html_path).expect("the HTML was written");
assert!(html.contains("<title>Report</title>"));
assert!(html.contains("<em>text</em>"));
assert!(!dirty(&smep, cx), "exporting is not saving");
}
#[gpui_kit::test]
fn menus_dispatch_from_whatever_is_on_screen(cx: &mut TestAppContext) {
let dir = TempDir::new("menu-focus");
let path = dir.path("menu.md");
std::fs::write(&path, "# T\n\npara").unwrap();
let (smep, cx) = open_document(cx, Document::read(path.clone()).unwrap());
let editor = editor(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
let focus = |cx: &VisualTestContext| cx.read(|cx| smep.read(cx).menu_focus(cx));
assert_eq!(
focus(cx),
cx.read(|cx| editor.focus_handle(cx)),
"split: the editor"
);
rendered_mode(&smep, cx);
assert_eq!(
focus(cx),
cx.read(|cx| rendered.read(cx).focus_handle().clone()),
"rendered, no block: the view itself"
);
activate_block(&smep, 5..9, cx);
let block = active_block(&smep, cx);
assert_eq!(focus(cx), cx.read(|cx| block.focus_handle(cx)), "the block");
type_text(&block, "para!", cx);
cx.simulate_keystrokes("escape");
cx.run_until_parked();
assert!(dirty(&smep, cx));
cx.update(|window, cx| {
let handle = smep.read(cx).menu_focus(cx);
handle.focus(window, cx);
window.dispatch_action(Box::new(Save), cx);
});
cx.run_until_parked();
assert!(!dirty(&smep, cx));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "# T\n\npara!");
}
#[gpui_kit::test]
fn a_large_document_is_highlighted_after_the_pause_not_on_the_keystroke(
cx: &mut TestAppContext,
) {
use crate::highlight::{self, MarkdownHighlighter};
use gpui_kit::HighlightStyle;
use gpui_kit::component::input::{HighlightStyleResolver, InputHighlighter};
struct Faded;
impl HighlightStyleResolver for Faded {
fn style(&self, _: &str) -> Option<HighlightStyle> {
Some(HighlightStyle {
fade_out: Some(0.5),
..Default::default()
})
}
}
let styled = |highlighter: &MarkdownHighlighter| {
highlighter
.styles(&(0..9), &Faded)
.iter()
.any(|(_, style)| style.fade_out.is_some())
};
let update = |highlighter: &mut MarkdownHighlighter,
editor: &Entity<EditorState>,
cx: &mut VisualTestContext| {
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
highlighter.update(None, state.text(), false, window, cx)
})
});
};
let big = "# Heading\n\ntext\n\n".repeat(highlight::SYNC_LIMIT / 16 + 1);
assert!(big.len() > highlight::SYNC_LIMIT);
let (smep, cx) = open(cx, &big);
let source = editor(&smep, cx);
let mut highlighter = MarkdownHighlighter::default();
update(&mut highlighter, &source, cx);
assert!(!styled(&highlighter), "nothing is parsed on the keystroke");
cx.executor().advance_clock(highlight::DEBOUNCE);
cx.run_until_parked();
assert!(styled(&highlighter), "parsed once typing paused");
let (smep, cx) = open(cx, "# Heading");
let source = editor(&smep, cx);
let mut highlighter = MarkdownHighlighter::default();
update(&mut highlighter, &source, cx);
assert!(styled(&highlighter));
}
#[gpui_kit::test]
fn block_edits_keep_the_block_list_equal_to_a_fresh_parse(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title\n\npara\n\n- x\n- y\n\n> quote");
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
let check = |cx: &mut VisualTestContext, when: &str| {
draw(cx);
let text = value(&document, cx);
assert_eq!(
cx.read(|cx| rendered.read(cx).block_ranges()),
crate::highlight::block_ranges(&text),
"{when}: {text:?}"
);
};
activate_block(&smep, 9..13, cx);
let block = active_block(&smep, cx);
for typed in ["para longer", "para\n\nsecond", "", "back"] {
type_text(&block, typed, cx);
check(cx, typed);
}
activate_block(&smep, 0..7, cx);
type_text(&active_block(&smep, cx), "## Small", cx);
check(cx, "first block");
cx.update(|window, cx| {
rendered.update(cx, |rendered, cx| rendered.activate_at_end(window, cx))
});
type_text(&active_block(&smep, cx), "tail", cx);
check(cx, "new block at the end");
type_text(&document, "# Other\n\nthing", cx);
check(cx, "source edit");
for start in ["", "a\r\n\r\nb\r\n"] {
let (smep, cx) = open(cx, start);
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
cx.update(|window, cx| {
rendered.update(cx, |rendered, cx| rendered.activate_at_end(window, cx))
});
for typed in ["x", "x\r\n\r\ny", ""] {
type_text(&active_block(&smep, cx), typed, cx);
draw(cx);
let text = value(&document, cx);
assert_eq!(
cx.read(|cx| rendered.read(cx).block_ranges()),
crate::highlight::block_ranges(&text),
"{start:?} then {typed:?}: {text:?}"
);
}
}
}
#[gpui_kit::test]
fn an_emptied_block_stays_on_screen_and_takes_typing(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title\n\npara\n\n- x");
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
activate_block(&smep, 9..13, cx);
let block = active_block(&smep, cx);
type_text(&block, "", cx);
assert_eq!(value(&document, cx), "# Title\n\n\n\n- x");
draw(cx);
cx.simulate_input("n");
cx.run_until_parked();
assert_eq!(value(&block, cx), "n");
assert_eq!(value(&document, cx), "# Title\n\nn\n\n- x");
}
#[gpui_kit::test]
fn a_change_from_elsewhere_ends_the_block_edit(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title\n\npara");
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
let active = |cx: &VisualTestContext| cx.read(|cx| rendered.read(cx).active_editor());
activate_block(&smep, 9..13, cx);
type_text(&document, "# Longer Title\n\npara", cx);
assert!(active(cx).is_none(), "the block's offsets went stale");
activate_block(&smep, 9..13, cx);
assert!(active(cx).is_some());
cx.update(|window, cx| {
smep.update(cx, |this, cx| {
this.load(
Document {
path: None,
text: "new".into(),
format: PreviewFormat::Markdown,
origin: None,
},
window,
cx,
)
})
});
assert!(active(cx).is_none());
activate_block(&smep, 0..3, cx);
let block = active_block(&smep, cx);
type_text(&block, "newer", cx);
type_text(&block, "newest", cx);
assert!(active(cx).is_some());
assert_eq!(value(&document, cx), "newest");
}
#[gpui_kit::test]
fn escape_ends_the_block_edit_and_keeps_the_text(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title\n\npara");
let document = editor(&smep, cx);
rendered_mode(&smep, cx);
activate_block(&smep, 9..13, cx);
let block = active_block(&smep, cx);
type_text(&block, "para!", cx);
cx.simulate_keystrokes("escape");
cx.run_until_parked();
let rendered = cx.read(|cx| smep.read(cx).rendered.clone());
assert!(cx.read(|cx| rendered.read(cx).active_editor().is_none()));
assert_eq!(value(&document, cx), "# Title\n\npara!");
assert!(
cx.update(|window, cx| rendered.read(cx).focus_handle().is_focused(window)),
"focus returns to the view so shortcuts keep working"
);
}
#[gpui_kit::test]
fn a_block_editor_grows_with_its_text(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "# Title\n\n- a\n- b\n- c\n- d");
rendered_mode(&smep, cx);
activate_block(&smep, 9..24, cx);
let block = active_block(&smep, cx);
draw(cx);
let line_height = cx
.read(|cx| block.read(cx).line_height())
.expect("laid out");
let height = cx.read(|cx| block.read(cx).input_bounds().size.height);
assert!(
height >= line_height * 4.,
"four lines need four rows: {height:?} < 4 × {line_height:?}"
);
}
#[gpui_kit::test]
fn the_menu_bar_and_fullscreen_toggle_by_keyboard(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
cx.update(|window, cx| editor.update(cx, |state, cx| state.focus(window, cx)));
draw(cx);
assert!(cx.read(|cx| smep.read(cx).menu_bar_shown()));
cx.simulate_keystrokes(&primary("shift-m"));
cx.run_until_parked();
assert!(!cx.read(|cx| smep.read(cx).menu_bar_shown()));
cx.simulate_keystrokes(&primary("shift-m"));
cx.run_until_parked();
assert!(cx.read(|cx| smep.read(cx).menu_bar_shown()));
let fullscreen = if cfg!(target_os = "macos") {
"ctrl-cmd-f"
} else {
"f11"
};
assert!(!cx.update(|window, _| window.is_fullscreen()));
cx.simulate_keystrokes(fullscreen);
cx.run_until_parked();
assert!(cx.update(|window, _| window.is_fullscreen()));
cx.simulate_keystrokes(fullscreen);
cx.run_until_parked();
assert!(!cx.update(|window, _| window.is_fullscreen()));
}
#[gpui_kit::test]
fn opening_a_path_from_the_os_asks_about_unsaved_changes_first(cx: &mut TestAppContext) {
let dir = TempDir::new("os-open");
let path = dir.path("incoming.md");
std::fs::write(&path, "# From Finder").unwrap();
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
smep.update(cx, |this, cx| {
this.open_document_at(path.clone(), window, cx)
})
});
cx.run_until_parked();
assert_eq!(value(&editor, cx), "# From Finder");
assert_eq!(cx.read(|cx| smep.read(cx).path.clone()), Some(path.clone()));
type_text(&editor, "edited", cx);
let other = dir.path("other.md");
std::fs::write(&other, "other").unwrap();
cx.update(|window, cx| {
smep.update(cx, |this, cx| {
this.open_document_at(other.clone(), window, cx)
})
});
assert!(cx.has_pending_prompt());
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert_eq!(value(&editor, cx), "edited");
cx.update(|window, cx| {
smep.update(cx, |this, cx| {
this.open_document_at(other.clone(), window, cx)
})
});
cx.simulate_prompt_answer("Don't Save");
cx.run_until_parked();
assert_eq!(value(&editor, cx), "other");
assert!(!dirty(&smep, cx));
cx.update(|window, cx| {
smep.update(cx, |this, cx| {
this.open_document_at(dir.path("missing.md"), window, cx)
})
});
cx.run_until_parked();
assert!(cx.has_pending_prompt(), "an error dialog is shown");
cx.simulate_prompt_answer("OK");
assert_eq!(value(&editor, cx), "other");
}
#[gpui_kit::test]
fn ctrl_s_reaches_the_save_action(cx: &mut TestAppContext) {
let dir = TempDir::new("keys");
let path = dir.path("keys.md");
std::fs::write(&path, "").unwrap();
let (smep, cx) = open_document(cx, Document::read(path.clone()).unwrap());
let editor = editor(&smep, cx);
cx.update(|window, cx| editor.update(cx, |state, cx| state.focus(window, cx)));
draw(cx);
type_text(&editor, "typed", cx);
assert!(dirty(&smep, cx));
let save = if cfg!(target_os = "macos") {
"cmd-s"
} else {
"ctrl-s"
};
cx.simulate_keystrokes(save);
cx.run_until_parked();
assert!(!dirty(&smep, cx));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "typed");
}
#[cfg(not(target_os = "macos"))]
#[gpui_kit::test]
fn ctrl_end_and_ctrl_home_jump_across_the_document(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "first\nsecond\nthird");
let editor = editor(&smep, cx);
cx.update(|window, cx| editor.update(cx, |state, cx| state.focus(window, cx)));
draw(cx);
cx.simulate_keystrokes("ctrl-end");
cx.run_until_parked();
assert_eq!(
cx.read(|cx| editor.read(cx).cursor()),
"first\nsecond\nthird".len()
);
cx.simulate_keystrokes("ctrl-home");
cx.run_until_parked();
assert_eq!(cx.read(|cx| editor.read(cx).cursor()), 0);
cx.simulate_keystrokes("ctrl-shift-end");
cx.run_until_parked();
assert_eq!(
cx.read(|cx| editor.read(cx).selected_range()),
0.."first\nsecond\nthird".len()
);
}
#[gpui_kit::test]
fn a_slash_on_an_empty_line_opens_the_menu_and_escape_closes_it(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "");
let editor = editor(&smep, cx);
cx.update(|window, cx| editor.update(cx, |state, cx| state.focus(window, cx)));
draw(cx);
cx.simulate_input("/");
assert!(
menu_open(&smep, cx),
"typing / on an empty line opens the menu"
);
draw(cx);
cx.simulate_keystrokes("escape");
cx.run_until_parked();
assert!(!menu_open(&smep, cx), "escape closes the menu");
assert_eq!(value(&editor, cx), "/", "escape keeps what was typed");
}
#[gpui_kit::test]
fn a_slash_inside_text_does_not_open_the_menu(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "a");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.focus(window, cx);
state.set_cursor_position(Position::new(0, 1), window, cx);
});
});
draw(cx);
cx.simulate_input("/");
assert_eq!(value(&editor, cx), "a/");
assert!(!menu_open(&smep, cx));
}
#[gpui_kit::test]
fn choosing_the_first_entry_replaces_the_line(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "intro\n\nend");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.focus(window, cx);
state.set_cursor_position(Position::new(1, 0), window, cx);
});
});
draw(cx);
cx.update(|window, cx| smep.update(cx, |this, cx| this.open_insert_menu(window, cx)));
assert!(menu_open(&smep, cx));
draw(cx);
cx.simulate_keystrokes("down enter");
cx.run_until_parked();
assert_eq!(value(&editor, cx), "intro\n# \nend");
assert_eq!(cx.read(|cx| editor.read(cx).cursor()), "intro\n# ".len());
assert!(!menu_open(&smep, cx), "the menu closes after a choice");
assert_eq!(
cx.read(|cx| smep.read(cx).source.to_string()),
"intro\n# \nend",
"the preview sees the inserted block"
);
}
#[gpui_kit::test]
fn a_click_below_the_text_opens_the_menu_at_the_end(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "one\ntwo");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.focus(window, cx);
state.set_cursor_position(Position::new(0, 0), window, cx);
});
});
draw(cx);
let (input, last) = cx.read(|cx| {
let state = editor.read(cx);
let len = state.text().len();
(
state.input_bounds(),
state
.range_to_bounds(&(len..len))
.expect("the last line is on screen"),
)
});
assert!(
input.bottom() > last.bottom() + px(40.),
"room below the text"
);
mouse_down(
cx,
MouseButton::Left,
f32::from(last.left()) + 4.,
f32::from(last.top()) + 4.,
);
assert!(!menu_open(&smep, cx));
mouse_down(
cx,
MouseButton::Left,
f32::from(last.left()) + 4.,
f32::from(input.bottom()) - 10.,
);
assert!(
menu_open(&smep, cx),
"a click below the text opens the menu"
);
assert_eq!(cx.read(|cx| editor.read(cx).cursor()), "one\ntwo".len());
}
#[gpui_kit::test]
fn right_click_in_the_editor_opens_the_context_menu(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "one\ntwo");
let editor = editor(&smep, cx);
cx.update(|window, cx| editor.update(cx, |state, cx| state.focus(window, cx)));
draw(cx);
let first = cx.read(|cx| editor.read(cx).range_to_bounds(&(0..0)).unwrap());
let (x, y) = (f32::from(first.left()) + 4., f32::from(first.top()) + 4.);
mouse_down(cx, MouseButton::Right, x, y);
cx.simulate_event(gpui_kit::MouseUpEvent {
button: MouseButton::Right,
position: point(px(x), px(y)),
modifiers: Default::default(),
click_count: 1,
});
cx.run_until_parked();
draw(cx);
cx.run_until_parked();
assert!(menu_open(&smep, cx), "right-click opens smep's menu");
assert!(!cx.read(|cx| {
smep.read(cx)
.insert_menu
.as_ref()
.unwrap()
.menu
.read(cx)
.is_empty()
}));
}
#[gpui_kit::test]
fn right_click_on_the_preview_picks_a_theme_and_saves_it(cx: &mut TestAppContext) {
let dir = TempDir::new("theme");
let settings = Settings {
preview_theme: PreviewTheme::System,
path: Some(dir.path("settings.toml")),
..Settings::default()
};
let document = Document {
path: None,
text: "# T".into(),
format: PreviewFormat::Markdown,
origin: None,
};
let (smep, cx) = open_with(cx, document, settings);
draw(cx);
let editor = editor(&smep, cx);
let preview_x = f32::from(cx.read(|cx| editor.read(cx).input_bounds().right())) + 60.;
mouse_down(cx, MouseButton::Right, preview_x, 200.);
assert!(
menu_open(&smep, cx),
"right-click on the preview opens the theme menu"
);
draw(cx);
cx.simulate_keystrokes("down down enter");
cx.run_until_parked();
assert_eq!(
cx.read(|cx| smep.read(cx).preview_theme()),
PreviewTheme::Paper
);
assert!(!menu_open(&smep, cx));
assert!(
std::fs::read_to_string(dir.path("settings.toml"))
.unwrap()
.contains("preview_theme = \"paper\"")
);
}
#[gpui_kit::test]
fn inserting_on_a_line_with_text_adds_a_line_below(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "text\nnext");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.set_cursor_position(Position::new(0, 2), window, cx);
insert::insert_block(state, BlockKind::Heading1, window, cx);
});
});
assert_eq!(value(&editor, cx), "text\n# \nnext");
assert_eq!(cx.read(|cx| editor.read(cx).cursor()), "text\n# ".len());
let (smep, cx) = open(cx, "a\r\nb");
let crlf_editor = cx.read(|cx| smep.read(cx).editor.clone());
cx.update(|window, cx| {
crlf_editor.update(cx, |state, cx| {
state.set_cursor_position(Position::new(0, 1), window, cx);
insert::insert_block(state, BlockKind::Quote, window, cx);
});
});
assert_eq!(value(&crlf_editor, cx), "a\r\n> \r\nb");
assert_eq!(cx.read(|cx| crlf_editor.read(cx).cursor()), "a\r\n> ".len());
}
#[gpui_kit::test]
fn insert_block_keeps_crlf_line_endings(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "a\r\n\r\nb");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.set_cursor_position(Position::new(1, 0), window, cx);
insert::insert_block(state, BlockKind::Heading2, window, cx);
});
});
assert_eq!(value(&editor, cx), "a\r\n## \r\nb");
assert_eq!(cx.read(|cx| editor.read(cx).cursor()), "a\r\n## ".len());
}
#[gpui_kit::test]
fn multi_line_snippets_put_the_cursor_inside(cx: &mut TestAppContext) {
let (smep, cx) = open(cx, "/");
let editor = editor(&smep, cx);
cx.update(|window, cx| {
editor.update(cx, |state, cx| {
state.set_cursor_position(Position::new(0, 1), window, cx);
insert::insert_block(state, BlockKind::CodeBlock, window, cx);
});
});
assert_eq!(value(&editor, cx), "```\n\n```");
assert_eq!(cx.read(|cx| editor.read(cx).cursor()), "```\n".len());
}
#[test]
fn html_files_get_the_html_preview() {
assert_eq!(
PreviewFormat::for_path(Path::new("a.html")),
PreviewFormat::Html
);
assert_eq!(
PreviewFormat::for_path(Path::new("a.HTM")),
PreviewFormat::Html
);
assert_eq!(
PreviewFormat::for_path(Path::new("a.md")),
PreviewFormat::Markdown
);
assert_eq!(
PreviewFormat::for_path(Path::new("README")),
PreviewFormat::Markdown
);
}
#[test]
fn titles_show_the_name_and_a_dirty_marker() {
assert_eq!(Smep::title_for(None, false), "Untitled — smep");
assert_eq!(
Smep::title_for(Some(Path::new("/x/a.md")), true),
"● a.md — smep"
);
}
}