use crate::{
memory,
model::{article, workspace::Showing},
view::{
leaf::Pane,
root::{Cydonia, NewArticle},
sidebar::{self, Row},
},
};
use artifact::space::Member;
use bezel::ui::scroll as scrollbars;
use bezel::{
gpui::{
self, AnyElement, App, Context, CursorStyle, Div, Entity, Focusable as _, KeyBinding,
MouseButton, ObjectFit, PathPromptOptions, SharedString, Window, actions, div, img,
prelude::*, px,
},
motion::{Fade, Painter},
theme::{ControlSize, Sizing as _, TextStyle, Theme, Typeset},
ui::{
icons,
input::TextField,
widgets::{ButtonStyle, Buttons as _, Status as _},
},
};
use editor::Mode;
use markdown::HighlightColor;
use std::{
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
};
actions!(cydonia_article, [LeaveTitle, TogglePlainText]);
pub fn bindings() -> Vec<KeyBinding> {
let ctx = Some(article::TITLE_CONTEXT);
vec![
KeyBinding::new("enter", LeaveTitle, ctx),
KeyBinding::new("down", LeaveTitle, ctx),
]
}
const CONTENT_MAX_WIDTH: f32 = 720.;
const COVER_HEIGHT: f32 = CONTENT_MAX_WIDTH / 5.;
const TAIL: f32 = 120.;
const COLUMN_INSET: f32 = 24.;
pub fn source_style(theme: &Theme) -> markdown::SourceStyle {
markdown::SourceStyle {
line_numbers: true,
gutter_min_digits: 1,
gutter_gap: 1.5,
gutter_color: Some(theme.text_faint),
}
}
pub fn marks() -> markdown::Marks {
markdown::Marks::new().with(editor::HIGHLIGHT_MARK, "==")
}
static HIGHLIGHT: AtomicUsize = AtomicUsize::new(0);
pub fn set_highlight(color: HighlightColor) {
let ix = HighlightColor::ALL
.iter()
.position(|c| *c == color)
.unwrap_or(0);
HIGHLIGHT.store(ix, Ordering::Relaxed);
}
pub fn mark_paint(name: &str, theme: &Theme) -> Option<markdown::MarkPaint> {
let color = HighlightColor::ALL[HIGHLIGHT.load(Ordering::Relaxed)];
(name == editor::HIGHLIGHT_MARK).then(|| markdown::MarkPaint {
background: Some(markdown::default_highlight(color, theme)),
..Default::default()
})
}
fn source_offset(editor: &editor::Editor, cx: &App) -> f32 {
if editor.mode() != Mode::Source {
return 0.;
}
let style = markdown::SourceStyle::of(cx);
let base = bezel::theme::base_text_size();
let limits = editor::TextSize::of(cx);
let size = ((editor.text_size().unwrap_or(base) + editor::text_size_adjustment(cx))
.clamp(limits.min, limits.max)
* 10.)
.round()
/ 10.;
let code_size = markdown::Typography::of(cx).scaled(size / base).code.size();
let digits = editor
.source()
.split('\n')
.count()
.to_string()
.len()
.max(style.gutter_min_digits);
12. + if style.line_numbers {
(digits as f32 + style.gutter_gap.max(0.)) * code_size
} else {
0.
}
}
fn column(wide: bool) -> Div {
let band = div().w_full();
match wide {
true => band,
false => band.max_w(px(CONTENT_MAX_WIDTH)),
}
}
impl Cydonia {
pub(crate) fn new_article_action(
&mut self,
_: &NewArticle,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(project) = self.workspace.read(cx).active else {
return;
};
self.new_article(project, window, cx);
}
pub(crate) fn new_article(
&mut self,
project: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.select_project(project, cx);
let ix = self
.workspace
.update(cx, |workspace, cx| workspace.new_article(cx));
if let Some(ix) = ix {
self.open_article(project, ix, window, cx);
}
}
pub(crate) fn open_article(
&mut self,
project: usize,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.commit(cx);
let member = self
.workspace
.read(cx)
.member_of(project, Showing::Article(ix));
if self.enter_member(member, window, cx) {
return;
}
self.workspace
.update(cx, |workspace, cx| workspace.open_article(project, ix, cx));
self.leaf_mut().pane = Pane::Article;
let (field, editor, unnamed) = {
let article = self.pane_doc(cx);
(
article.and_then(|article| article.field.clone()),
article.and_then(|article| article.editor.clone()),
article.is_some_and(|article| article.title.is_empty()),
)
};
match (unnamed, field, editor) {
(true, Some(field), _) => window.focus(&field.focus_handle(cx), cx),
(_, _, Some(editor)) => window.focus(&editor.focus_handle(cx), cx),
_ => {}
}
cx.notify();
}
pub(crate) fn pane_doc<'a>(&self, cx: &'a App) -> Option<&'a crate::model::article::Article> {
self.workspace
.read(cx)
.article_of(self.leaf().entry.as_ref())
}
pub(crate) fn pane_article(&self, cx: &App) -> Option<std::path::PathBuf> {
self.pane_doc(cx).map(|article| article.path.clone())
}
fn leave_title(&mut self, _: &LeaveTitle, window: &mut Window, cx: &mut Context<Self>) {
let editor = self.pane_doc(cx).and_then(|article| article.editor.clone());
if let Some(editor) = editor {
window.focus(&editor.focus_handle(cx), cx);
}
}
pub(crate) fn toggle_plain_text(
&mut self,
_: &TogglePlainText,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(mode) = self.plain_text(cx) else {
return;
};
let mode = match mode {
true => Mode::Blocks,
false => Mode::Source,
};
let Some(on) = self.pane_article(cx) else {
return;
};
self.workspace.update(cx, |workspace, cx| {
workspace.set_article_mode(&on, mode, cx)
});
let editor = self.pane_doc(cx).and_then(|article| article.editor.clone());
if let Some(editor) = editor {
window.focus(&editor.focus_handle(cx), cx);
}
cx.notify();
}
pub(crate) fn plain_text(&self, cx: &App) -> Option<bool> {
let article = self.pane_doc(cx)?;
Some(article.mode(cx) == Mode::Source)
}
pub(crate) fn set_full_width(&mut self, wide: Option<bool>, cx: &mut Context<Self>) {
let Some(on) = self.pane_article(cx) else {
return;
};
self.workspace
.update(cx, |workspace, cx| workspace.set_full_width(&on, wide, cx));
cx.notify();
}
pub(crate) fn shuffle_cover(&mut self, cx: &mut Context<Self>) {
let Some(on) = self.pane_article(cx) else {
return;
};
self.workspace
.update(cx, |workspace, cx| workspace.shuffle_cover(&on, cx));
cx.notify();
}
fn pick_cover(&mut self, cx: &mut Context<Self>) {
let Some(on) = self.pane_article(cx) else {
return;
};
let picked = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
let Ok(Ok(Some(paths))) = picked.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
let _ = this.update(cx, |this, cx| {
this.workspace.update(cx, |workspace, cx| {
workspace.set_cover(&on, Some(&path), cx)
});
cx.notify();
});
})
.detach();
}
fn remove_cover(&mut self, cx: &mut Context<Self>) {
let Some(on) = self.pane_article(cx) else {
return;
};
self.workspace
.update(cx, |workspace, cx| workspace.set_cover(&on, None, cx));
cx.notify();
}
fn revert_article(&mut self, path: &Path, window: &mut Window, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.revert_article(path, cx));
self.follow_article(window, cx);
cx.notify();
}
pub(crate) fn follow_article(&self, window: &mut Window, cx: &mut Context<Self>) {
if self.showing(cx) != Some(Pane::Article) {
return;
}
let article = self.pane_doc(cx);
let titling = article
.and_then(|article| article.field.clone())
.is_some_and(|field| field.focus_handle(cx).contains_focused(window, cx));
let editor = article.and_then(|article| article.editor.clone());
if let Some(editor) = editor.filter(|_| !titling) {
window.focus(&editor.focus_handle(cx), cx);
}
}
fn keep_article(&mut self, path: &Path, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.keep_article(path, cx));
cx.notify();
}
pub(crate) fn article(
&self,
project: usize,
at: usize,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let article = self.workspace.read(cx).article_in(project, at)?;
let field = article.field.clone()?;
let editor = article.editor.clone()?;
let cover = article.cover.clone();
let wide = article.wide(self.workspace.read(cx).wide_pages);
let source_offset = source_offset(editor.read(cx), cx);
let stale = article.stale.then(|| article.path.clone());
let document = div()
.id("article")
.on_action(cx.listener(Self::leave_title))
.capture_any_mouse_down(cx.listener(|this, _, _, cx| this.set_selecting(true, cx)))
.capture_any_mouse_up(cx.listener(|this, _, _, cx| this.set_selecting(false, cx)))
.on_mouse_up_out(
MouseButton::Left,
cx.listener(|this, _, _, cx| this.set_selecting(false, cx)),
)
.flex_1()
.min_h_0()
.w_full()
.overflow_y_scroll()
.track_scroll(&article.scroll)
.flex()
.flex_col()
.child(self.header(cover, field, wide, cx))
.child(
div()
.w_full()
.flex_none()
.min_h_full()
.flex()
.justify_center()
.cursor(CursorStyle::IBeam)
.on_mouse_down(MouseButton::Left, {
let editor = editor.clone();
move |event, window, cx| {
editor.update(cx, |editor, cx| {
editor.press(
event.position,
event.click_count,
event.modifiers,
window,
cx,
)
})
}
})
.child(
column(wide)
.px(px(COLUMN_INSET))
.pt(px(20.))
.pb(px(TAIL))
.flex()
.child(
div()
.flex_1()
.min_w_0()
.ml(px(-source_offset))
.child(editor.clone()),
),
),
);
Some(
div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_col()
.on_mouse_down_out({
let editor = editor.clone();
move |_, window, cx| {
if editor.focus_handle(cx).is_focused(window) {
window.blur(cx);
}
}
})
.children(stale.map(|path| self.stale_notice(path, cx)))
.child(
div()
.relative()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.child(document)
.child(scrollbars::Overlay::new(
"article-bar",
&article.scroll,
bezel::gpui::Axis::Vertical,
)),
)
.children(self.ribbon(on, window, cx))
.into_any_element(),
)
}
fn stale_notice(&self, path: PathBuf, cx: &Context<Self>) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let painter = Painter::of(cx);
let chip = |id: &'static str, label: &'static str| {
theme
.button(label, ButtonStyle::Ghost, Some(Fade::new(painter, id)))
.control_size(ControlSize::Small)
.id(id)
};
let keep = path.clone();
theme
.warning_strip("This file changed on disk while you were editing it.")
.mx(px(COLUMN_INSET))
.flex_none()
.items_center()
.child(
theme
.control_group()
.ml_auto()
.flex_none()
.child(chip("article-revert", "Reload").on_click(cx.listener(
move |this, _, window, cx| this.revert_article(&path, window, cx),
)))
.child(
chip("article-keep", "Keep mine").on_click(
cx.listener(move |this, _, _, cx| this.keep_article(&keep, cx)),
),
),
)
}
fn header(
&self,
cover: Option<PathBuf>,
field: Entity<TextField>,
wide: bool,
cx: &Context<Self>,
) -> impl IntoElement + use<> {
div()
.w_full()
.flex_none()
.flex()
.flex_col()
.children(cover.map(|cover| self.cover_band(cover, cx)))
.child(
div().w_full().flex().justify_center().child(
column(wide)
.pl(px(COLUMN_INSET + editor::Layout::of(cx).text_inset))
.pr(px(COLUMN_INSET))
.pt(px(20.))
.child(field),
),
)
}
fn cover_band(&self, cover: PathBuf, cx: &Context<Self>) -> impl IntoElement + use<> {
div()
.group("cover")
.relative()
.w_full()
.flex_none()
.h(px(COVER_HEIGHT))
.overflow_hidden()
.child(
img(cover)
.size_full()
.object_fit(ObjectFit::Cover)
.image_cache(&memory::covers(cx)),
)
.child(self.cover_controls(cx))
}
fn cover_controls(&self, cx: &Context<Self>) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let painter = Painter::of(cx);
let chip = |id: &'static str, label: &'static str| {
theme
.button(label, ButtonStyle::Ghost, Some(Fade::new(painter, id)))
.control_size(ControlSize::Small)
.id(id)
};
theme
.control_group()
.absolute()
.bottom(px(10.))
.right(px(10.))
.invisible()
.group_hover("cover", |row| row.visible())
.child(
chip("cover-shuffle", "Shuffle")
.on_click(cx.listener(|this, _, _, cx| this.shuffle_cover(cx))),
)
.child(
chip("cover-change", "Change")
.on_click(cx.listener(|this, _, _, cx| this.pick_cover(cx))),
)
.child(
chip("cover-remove", "Remove")
.on_click(cx.listener(|this, _, _, cx| this.remove_cover(cx))),
)
}
pub(crate) fn article_row(
&self,
project: usize,
ix: usize,
title: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let workspace = self.workspace.read(cx);
let selected = !self.arranged(cx)
&& self.showing(cx) == Some(Pane::Article)
&& workspace.active == Some(project)
&& workspace
.projects
.get(project)
.is_some_and(|open| open.article == Some(ix));
let entry = Row::Article { project, ix };
let article = workspace
.projects
.get(project)
.and_then(|open| open.articles.get(ix));
let archived = article.is_some_and(|article| article.archived);
let tint = sidebar::tint(selected, archived, &theme);
let id = SharedString::from(format!("article-{project}-{ix}"));
sidebar::row(
id,
"article-row",
selected,
self.indent_of(entry, cx),
&theme,
)
.child(
icons::icon(icons::files::FileText)
.size(px(14.))
.flex_none()
.text_color(tint),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Body)
.text_color(tint)
.child(title),
)
.child(self.archive_button(
format!("article-archive-{ix}"),
"article-row",
entry,
archived,
window,
cx,
))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_article(project, ix, window, cx);
}))
}
}