use crate::{
memory,
model::article,
view::{
component::menu::Menu,
root::{Cydonia, NewArticle, Pane},
sidebar::{self, Renaming, Row},
},
};
use bezel::{
gpui::{
self, AnyElement, App, Context, CursorStyle, Entity, Focusable as _, KeyBinding, 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 _},
},
};
use std::path::PathBuf;
actions!(cydonia_article, [LeaveTitle]);
pub fn init(cx: &mut App) {
let ctx = Some(article::TITLE_CONTEXT);
cx.bind_keys([
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.;
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);
}
}
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();
}
pub(crate) fn article(&self, cx: &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();
Some(
div()
.id("article")
.on_action(cx.listener(Self::leave_title))
.flex_1()
.min_h_0()
.w_full()
.overflow_y_scroll()
.track_scroll(&article.scroll)
.flex()
.flex_col()
.child(self.header(cover, field, cx))
.child(
div()
.w_full()
.flex_none()
.min_h_full()
.flex()
.justify_center()
.child(
div()
.w_full()
.max_w(px(CONTENT_MAX_WIDTH))
.px(px(COLUMN_INSET))
.py(px(20.))
.flex()
.cursor(CursorStyle::IBeam)
.child(editor),
),
)
.into_any_element(),
)
}
fn header(
&self,
cover: Option<PathBuf>,
field: Entity<TextField>,
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(
div()
.w_full()
.max_w(px(CONTENT_MAX_WIDTH))
.pl(px(COLUMN_INSET + editor::Layout::of(cx).text_inset))
.pr(px(COLUMN_INSET))
.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 path = article.map(|article| &article.path);
let renaming = matches!(&self.renaming, Some(Renaming::Article(at)) if Some(at) == path);
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::DOCUMENT)
.size(px(14.))
.flex_none()
.text_color(tint),
)
.child(match renaming {
true => self.name_field(cx),
false => div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Body)
.text_color(tint)
.child(title)
.into_any_element(),
})
.child(
self.menu_button(
("article-menu", ix),
"article-row",
icons::icon(icons::system::MENU_DOTS)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Entry(entry),
cx,
)
.children(self.entry_menu(entry, archived, cx)),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_article(project, ix, window, cx);
}))
}
}