use crate::view::{
component::menu::{self, Menu},
root::Cydonia,
};
use bezel::{
gpui::{
self, Anchor, AnyElement, Bounds, Context, Entity, Focusable as _, KeyBinding, Pixels,
Point, SharedString, Window, actions, div, point, prelude::*, px,
},
motion,
theme::{TextStyle, Theme, Typeset},
ui::{
icons::{self, Icon},
input::TextField,
menu::Item,
popover, surface,
tooltip::Tooltip,
widgets::Buttons as _,
},
};
use editor::{Editor, Formatting, Mode};
use markdown::{BlockKind, Mark};
actions!(cydonia_ribbon, [ConfirmLink, DismissLink]);
pub const LINK_CONTEXT: &str = "CydoniaArticleLink";
pub fn bindings() -> Vec<KeyBinding> {
let ctx = Some(LINK_CONTEXT);
vec![
KeyBinding::new("enter", ConfirmLink, ctx),
KeyBinding::new("escape", DismissLink, ctx),
]
}
const GAP: f32 = 8.;
const BUTTON: f32 = 26.;
const FIELD_WIDTH: f32 = 240.;
pub(crate) struct Ribbon {
pub(crate) selecting: bool,
pub(crate) linking: Option<Linking>,
pub(crate) field: Entity<TextField>,
}
impl Ribbon {
pub(crate) fn new(cx: &mut Context<Cydonia>) -> Self {
let field = cx.new(|cx| {
TextField::new(cx)
.with_frame(false)
.with_key_context(LINK_CONTEXT)
.with_placeholder("Paste a link…")
});
Self {
selecting: false,
linking: None,
field,
}
}
}
pub(crate) struct Linking {
pub(crate) replacing: Option<String>,
}
pub struct Format {
pub mark: Mark,
pub label: &'static str,
pub icon: Icon,
pub lit: bool,
}
pub fn formats(formatting: &Formatting) -> Vec<Format> {
let code = match formatting.fenceable {
true => "Code block",
false => "Code",
};
[
(Mark::Bold, "Bold", icons::text::Bold),
(Mark::Italic, "Italic", icons::text::Italic),
(Mark::Strike, "Strikethrough", icons::text::Strikethrough),
(Mark::Code, code, icons::text::Code),
]
.into_iter()
.map(|(mark, label, icon)| Format {
lit: formatting.marks.contains(&mark),
mark,
label,
icon: Icon::glyph(icon),
})
.collect()
}
pub fn keystroke(mark: &Mark) -> Option<&'static str> {
match mark {
Mark::Italic => Some("⌘I"),
Mark::Strike => Some("⇧⌘X"),
_ => None,
}
}
pub fn link(formatting: &Formatting) -> Option<&str> {
formatting.marks.iter().find_map(|mark| match mark {
Mark::Link(url) => Some(url.as_str()),
_ => None,
})
}
pub fn perch(view: Bounds<Pixels>, head: Bounds<Pixels>) -> Option<Point<Pixels>> {
let top = head.origin.y;
let bottom = top + head.size.height;
(top >= view.origin.y && bottom <= view.origin.y + view.size.height)
.then(|| point(head.origin.x, top - px(GAP)))
}
impl Cydonia {
pub(crate) fn set_selecting(&mut self, selecting: bool, cx: &mut Context<Self>) {
if self.ribbon.selecting != selecting {
self.ribbon.selecting = selecting;
cx.notify();
}
}
pub(crate) fn rest_ribbon(&mut self, cx: &mut Context<Self>) {
if self.ribbon.linking.take().is_some() {
self.ribbon.field.update(cx, |field, cx| field.clear(cx));
cx.notify();
}
self.set_selecting(false, cx);
}
fn turn_blocks(&mut self, kind: BlockKind, cx: &mut Context<Self>) {
let Some(editor) = self.open_editor(cx) else {
return;
};
editor.update(cx, |editor, cx| {
let (start, end) = editor.selection().ordered();
for ix in start.block..=end.block {
editor.set_block(ix, kind.clone(), cx);
}
});
}
fn open_link(
&mut self,
replacing: Option<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let held = replacing.clone().unwrap_or_default();
self.ribbon
.field
.update(cx, |field, cx| field.set_content(held, cx));
self.ribbon.linking = Some(Linking { replacing });
window.focus(&self.ribbon.field.read(cx).focus_handle(cx), cx);
cx.notify();
}
fn confirm_link(&mut self, _: &ConfirmLink, window: &mut Window, cx: &mut Context<Self>) {
let Some(linking) = self.ribbon.linking.take() else {
return;
};
let url = self.ribbon.field.read(cx).content().trim().to_string();
if let Some(editor) = self.open_editor(cx) {
editor.update(cx, |editor, cx| {
if let Some(old) = linking.replacing {
editor.toggle_mark(Mark::Link(old), cx);
}
if !url.is_empty() {
editor.toggle_mark(Mark::Link(url), cx);
}
});
window.focus(&editor.focus_handle(cx), cx);
}
cx.notify();
}
fn dismiss_link(&mut self, _: &DismissLink, window: &mut Window, cx: &mut Context<Self>) {
if self.ribbon.linking.take().is_none() {
return;
}
if let Some(editor) = self.open_editor(cx) {
window.focus(&editor.focus_handle(cx), cx);
}
cx.notify();
}
fn open_editor(&self, cx: &Context<Self>) -> Option<Entity<Editor>> {
self.workspace
.read(cx)
.active_article()
.and_then(|article| article.editor.clone())
}
pub(crate) fn ribbon(&self, window: &Window, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.ribbon.selecting {
return None;
}
let article = self.workspace.read(cx).active_article()?;
let editor = article.editor.clone()?;
let port = article.scroll.bounds();
let (formatting, head) = {
let held = editor.read(cx);
(held.formatting(), held.selection_bounds()?)
};
if formatting.mode != Mode::Blocks {
return None;
}
let linking = self.ribbon.linking.is_some();
if !linking && !editor.focus_handle(cx).contains_focused(window, cx) {
return None;
}
let at = perch(port, head)?;
let theme = Theme::of(cx).clone();
let card = popover::popover_card(&theme)
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.on_action(cx.listener(Self::confirm_link))
.on_action(cx.listener(Self::dismiss_link));
let card = match linking {
true => card.child(self.link_field(&theme, cx)),
false => card
.child(self.turn_trigger(&formatting, &theme, cx))
.child(divider(&theme))
.children(
formats(&formatting)
.into_iter()
.map(|format| self.mark_button(format, editor.clone(), &theme, cx)),
)
.child(self.link_button(&formatting, &theme, cx)),
};
Some(floated("article-ribbon", at, card.into_any_element()))
}
fn mark_button(
&self,
format: Format,
editor: Entity<Editor>,
theme: &Theme,
cx: &Context<Self>,
) -> impl IntoElement + use<> {
let Format {
mark,
label,
icon,
lit,
} = format;
let tint = match lit {
true => theme.accent,
false => theme.text_muted,
};
let key = keystroke(&mark);
theme
.ghost(SharedString::from(format!("ribbon-{label}")))
.w(px(BUTTON))
.h(px(BUTTON))
.justify_center()
.child(icons::icon(icon).size(px(15.)).text_color(tint))
.tooltip(move |window, cx| match key {
Some(key) => Tooltip::with_keystroke(label, key, window, cx),
None => Tooltip::text(label, window, cx),
})
.on_click(cx.listener(move |_, _, _, cx| {
editor.update(cx, |editor, cx| editor.toggle_mark(mark.clone(), cx));
}))
}
fn link_button(
&self,
formatting: &Formatting,
theme: &Theme,
cx: &Context<Self>,
) -> impl IntoElement + use<> {
let held = link(formatting).map(str::to_owned);
let label = match held.is_some() {
true => "Edit link",
false => "Link",
};
let tint = match held.is_some() {
true => theme.accent,
false => theme.text_muted,
};
theme
.ghost("ribbon-link")
.w(px(BUTTON))
.h(px(BUTTON))
.justify_center()
.child(
icons::icon(icons::text::Link)
.size(px(15.))
.text_color(tint),
)
.tooltip(move |window, cx| Tooltip::text(label, window, cx))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_link(held.clone(), window, cx);
}))
}
fn link_field(&self, theme: &Theme, cx: &Context<Self>) -> impl IntoElement + use<> {
div()
.w(px(FIELD_WIDTH))
.h(px(BUTTON))
.px(px(6.))
.flex()
.items_center()
.text_style(TextStyle::Callout)
.text_color(theme.text)
.on_mouse_down_out(cx.listener(|this, _, window, cx| {
this.confirm_link(&ConfirmLink, window, cx);
}))
.child(self.ribbon.field.clone())
}
fn turn_trigger(
&self,
formatting: &Formatting,
theme: &Theme,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let current = formatting.block.clone();
let button = theme
.ghost("ribbon-turn")
.h(px(BUTTON))
.px(px(6.))
.gap(px(4.))
.relative()
.text_style(TextStyle::Callout)
.text_color(theme.text)
.child(
current.clone().unwrap_or_else(|| "Turn into".into()),
)
.child(
icons::icon(icons::arrows::ChevronDown)
.size(px(12.))
.text_color(theme.text_faint),
)
.on_click(cx.listener(|this, _, _, cx| {
cx.stop_propagation();
this.toggle_menu(Menu::Turn, cx);
}))
.children(self.turn_menu(current, cx));
self.menu_press(button, Menu::Turn, cx)
}
fn turn_menu(
&self,
current: Option<SharedString>,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if self.menu.as_ref() != Some(&Menu::Turn) {
return None;
}
let rows = editor::turns()
.into_iter()
.map(|(label, kind)| {
let checked = current.as_ref() == Some(&label);
menu::row(Item::action(label).checked(checked), move |this, _, cx| {
this.turn_blocks(kind.clone(), cx);
})
})
.collect();
let id = SharedString::from("ribbon-turn-card");
Some(popover::anchored_menu_below(
id.clone(),
self.menu_card(id, rows, cx),
None,
))
}
}
fn divider(theme: &Theme) -> impl IntoElement {
div()
.w(px(1.))
.h(px(16.))
.mx(px(2.))
.flex_none()
.bg(theme.border)
}
fn floated(id: &'static str, at: Point<Pixels>, content: AnyElement) -> AnyElement {
gpui::deferred(
gpui::anchored()
.position(at)
.anchor(Anchor::BottomLeft)
.child(motion::menu_in(
id,
div()
.occlude()
.child(surface::popover(Theme::surface_radius(), content)),
)),
)
.priority(1)
.into_any_element()
}