use crate::{
memory,
model::article,
view::{
component::menu::Menu,
root::{Cydonia, NewArticle, Pane},
sidebar::{self, Row},
},
};
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 std::path::{Path, PathBuf};
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 COLUMN_INSET: f32 = 24.;
const WIDE_INSET: f32 = COLUMN_INSET * 2.;
fn column(wide: bool) -> Div {
let band = div().w_full();
match wide {
true => band,
false => band.max_w(px(CONTENT_MAX_WIDTH)),
}
}
fn inset(wide: bool) -> f32 {
match wide {
true => WIDE_INSET,
false => COLUMN_INSET,
}
}
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);
self.workspace
.update(cx, |workspace, cx| workspace.open_article(project, ix, cx));
self.pane = Pane::Article;
let (field, editor, unnamed) = {
let article = self.workspace.read(cx).active_article();
(
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();
}
fn leave_title(&mut self, _: &LeaveTitle, window: &mut Window, cx: &mut Context<Self>) {
let editor = self
.workspace
.read(cx)
.active_article()
.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,
};
self.workspace
.update(cx, |workspace, cx| workspace.set_article_mode(mode, cx));
let editor = self
.workspace
.read(cx)
.active_article()
.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.workspace.read(cx).active_article()?;
Some(article.mode(cx) == Mode::Source)
}
pub(crate) fn set_full_width(&mut self, wide: Option<bool>, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.set_full_width(wide, cx));
cx.notify();
}
fn shuffle_cover(&mut self, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.shuffle_cover(cx));
cx.notify();
}
fn pick_cover(&mut self, cx: &mut Context<Self>) {
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(Some(&path), cx));
cx.notify();
});
})
.detach();
}
fn remove_cover(&mut self, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.set_cover(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.workspace.read(cx).active_article();
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,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let article = self.workspace.read(cx).active_article()?;
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 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()
.child(
column(wide)
.px(px(inset(wide)))
.py(px(20.))
.flex()
.cursor(CursorStyle::IBeam)
.child(editor),
),
);
Some(
div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_col()
.children(stale.map(|path| self.stale_notice(path, cx)))
.child(document)
.children(self.ribbon(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()
.child(self.cover_band(cover, cx))
.child(
div().w_full().flex().justify_center().child(
column(wide)
.pl(px(inset(wide) + editor::Layout::of(cx).text_inset))
.pr(px(inset(wide)))
.pt(px(20.))
.child(field),
),
)
}
fn cover_band(&self, cover: Option<PathBuf>, cx: &Context<Self>) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let has_cover = cover.is_some();
div()
.group("cover")
.relative()
.w_full()
.flex_none()
.h(px(COVER_HEIGHT))
.overflow_hidden()
.when(!has_cover, |band| {
band.bg(theme.element_hover)
.border_b_1()
.border_color(theme.border)
})
.children(cover.map(|path| {
img(path)
.size_full()
.object_fit(ObjectFit::Cover)
.image_cache(&memory::covers(cx))
}))
.child(self.cover_controls(has_cover, cx))
}
fn cover_controls(&self, has_cover: bool, 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",
if has_cover { "Shuffle" } else { "Add cover" },
)
.on_click(cx.listener(|this, _, _, cx| this.shuffle_cover(cx))),
)
.when(has_cover, |row| {
row.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,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let workspace = self.workspace.read(cx);
let selected = 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, &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.menu_button(
("article-menu", ix),
Some("article-row"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Entry(entry),
cx,
)
.children(self.entry_menu(Menu::Entry(entry), entry, archived, cx)),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_article(project, ix, window, cx);
}))
}
}