Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,291 @@
|
||||
//! Comments on slides, beside the canvas (SHOW-08).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
collab::{Comment, CommentSidebar, Peer, Thread},
|
||||
feedback::InlineMessage,
|
||||
forms::TextInput,
|
||||
overlays::PromptDialog,
|
||||
primitives::Severity,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_lok::show::Remark;
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, SharedString,
|
||||
Styled as _, Window, div, prelude::FluentBuilder as _,
|
||||
};
|
||||
|
||||
use crate::deck::Deck;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
/// Each comment as a thread quoted with its slide.
|
||||
fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
|
||||
let authors: Vec<&str> = remarks
|
||||
.iter()
|
||||
.map(|remark| remark.author.as_str())
|
||||
.collect();
|
||||
remarks
|
||||
.iter()
|
||||
.map(|remark| {
|
||||
let at = remark
|
||||
.at
|
||||
.parse::<jiff::civil::DateTime>()
|
||||
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
|
||||
.map_err(|error| format!("a bad comment time {}: {error}", remark.at))?;
|
||||
let hue = authors
|
||||
.iter()
|
||||
.position(|name| *name == remark.author)
|
||||
.expect("the author is listed");
|
||||
let slide = (remark.part + 1).to_string();
|
||||
Ok(Thread {
|
||||
key: remark.id.clone().into(),
|
||||
quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])),
|
||||
comments: vec![Comment {
|
||||
author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
|
||||
at: at.timestamp(),
|
||||
body: remark.text.clone().into(),
|
||||
reactions: Vec::new(),
|
||||
}],
|
||||
resolved: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Opens the comment dialog: new, or an edit of `edit`.
|
||||
pub fn ask_remark(
|
||||
&mut self,
|
||||
edit: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let text = match &edit {
|
||||
None => String::new(),
|
||||
Some(id) => match tab.extra.remarks.iter().find(|remark| &remark.id == id) {
|
||||
Some(remark) => remark.text.clone(),
|
||||
None => return log::warn!("show: comment {id} went before its edit"),
|
||||
},
|
||||
};
|
||||
let (tab, label) = (tab.id, Deck::text(cx, "show.remark.text", &[]));
|
||||
let field = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).multi_line(3, 8).label(label);
|
||||
input.set_text(text, cx);
|
||||
input
|
||||
});
|
||||
self.dialog = Some(Dialog::Remark { tab, edit, field });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sends the dialog's text: a new comment or an edit.
|
||||
pub(crate) fn send_remark(
|
||||
&mut self,
|
||||
(tab, edit): (u64, Option<String>),
|
||||
text: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let text = text.trim();
|
||||
match edit {
|
||||
None => self.run_in(Some(tab), "comment.insert", &[text], cx),
|
||||
Some(id) => self.run_in(Some(tab), "remark.edit", &[&id, text], cx),
|
||||
}
|
||||
self.force_reread(tab, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn remark_dialog(
|
||||
&self,
|
||||
(tab, edit): (u64, &Option<String>),
|
||||
field: &gpui::Entity<TextInput>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
let label = |key: &str| Deck::text(cx, key, &[]);
|
||||
let title = match edit {
|
||||
None => "show.remark.new_title",
|
||||
Some(_) => "show.remark.edit_title",
|
||||
};
|
||||
let closing = this.clone();
|
||||
let dismiss = move |_: &mut Window, cx: &mut App| {
|
||||
closing.update(cx, |deck, cx| {
|
||||
deck.dialog = None;
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
let (edit, blank) = (edit.clone(), label("show.remark.blank"));
|
||||
PromptDialog::new("show-remark", label(title), field, dismiss)
|
||||
.submit(label("show.remark.save"))
|
||||
.check(move |text| match text.trim().is_empty() {
|
||||
true => Err(blank.clone()),
|
||||
false => Ok(()),
|
||||
})
|
||||
.on_submit(move |text, _, cx| {
|
||||
this.update(cx, |deck, cx| {
|
||||
deck.send_remark((tab, edit.clone()), text, cx)
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The picked comment, while its slide is shown.
|
||||
pub(crate) fn shown_remark(&self) -> Option<SharedString> {
|
||||
let tab = self.active_tab()?;
|
||||
let part = tab.extra.slides.current;
|
||||
let key = self.active_remark.clone()?;
|
||||
let here = |remark: &Remark| remark.id == key.as_ref() && remark.part == part;
|
||||
tab.extra.remarks.iter().any(here).then_some(key)
|
||||
}
|
||||
|
||||
/// Shows comment `key`'s slide and marks it.
|
||||
pub(crate) fn pick_remark(&mut self, key: &SharedString, cx: &mut Context<Self>) {
|
||||
self.active_remark = Some(key.clone());
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
if let Some(remark) = tab
|
||||
.extra
|
||||
.remarks
|
||||
.iter()
|
||||
.find(|remark| remark.id == key.as_ref())
|
||||
{
|
||||
let (view, part) = (tab.view.clone(), remark.part);
|
||||
log::info!("show: comment {key} on slide {part}");
|
||||
view.update(cx, |view, _| view.show_part(part));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The comments column.
|
||||
pub fn remarks_view(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
let remarks = self
|
||||
.active_tab()
|
||||
.map(|tab| tab.extra.remarks.clone())
|
||||
.unwrap_or_default();
|
||||
let threads = match threads(&remarks, cx) {
|
||||
Ok(threads) => threads,
|
||||
Err(error) => {
|
||||
log::error!("show: {error}");
|
||||
let message = InlineMessage::new(Severity::Danger, error);
|
||||
return div().p_2().child(message).into_any_element();
|
||||
}
|
||||
};
|
||||
let active = self.shown_remark();
|
||||
let act = |id: &'static str,
|
||||
key: &str,
|
||||
on: bool,
|
||||
run: fn(&mut Deck, String, &mut Window, &mut Context<Deck>)| {
|
||||
let (this, active) = (this.clone(), active.clone());
|
||||
Button::new(id, Deck::text(cx, key, &[]))
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.disabled(!on)
|
||||
.on_click(move |_, window, cx| {
|
||||
let id = active
|
||||
.clone()
|
||||
.map(|key| key.to_string())
|
||||
.unwrap_or_default();
|
||||
this.update(cx, |deck, cx| run(deck, id, window, cx))
|
||||
})
|
||||
};
|
||||
let some = active.is_some();
|
||||
let head = div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.child(act(
|
||||
"show-remark-add",
|
||||
"show.remark.add",
|
||||
true,
|
||||
|deck, _, window, cx| deck.ask_remark(None, window, cx),
|
||||
))
|
||||
.child(act(
|
||||
"show-remark-edit",
|
||||
"show.remark.edit",
|
||||
some,
|
||||
|deck, id, window, cx| deck.ask_remark(Some(id), window, cx),
|
||||
))
|
||||
.child(act(
|
||||
"show-remark-delete",
|
||||
"show.remark.delete",
|
||||
some,
|
||||
|deck, id, _, cx| deck.delete_remark(&id, cx),
|
||||
));
|
||||
let pick = this.clone();
|
||||
let sidebar = CommentSidebar::new("show-remarks", threads)
|
||||
.active(active, move |key, _, cx| {
|
||||
pick.update(cx, |deck, cx| deck.pick_remark(key, cx))
|
||||
});
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.child(head)
|
||||
.child(div().flex_1().min_h_0().child(sidebar))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn delete_remark(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
if let Some(tab) = self.core.active {
|
||||
self.active_remark = None;
|
||||
self.run_in(Some(tab), "remark.delete", &[id], cx);
|
||||
self.force_reread(tab, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comments column as a view of the deck.
|
||||
pub struct Pane {
|
||||
pub deck: gpui::WeakEntity<Deck>,
|
||||
}
|
||||
|
||||
impl gpui::Render for Pane {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(deck) = self.deck.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
deck.update(cx, |deck, cx| deck.remarks_view(cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// A pin on the shown slide for each comment there.
|
||||
pub fn remark_layer(
|
||||
&self,
|
||||
view: &gpui::Entity<ely_lok::LokView>,
|
||||
cx: &App,
|
||||
) -> Option<AnyElement> {
|
||||
let tab = self.active_tab()?;
|
||||
let part = tab.extra.slides.current;
|
||||
let lok = view.read(cx);
|
||||
let theme = ely_gpui_component::theme::ActiveTheme::theme(cx);
|
||||
let pins: Vec<AnyElement> = (tab.extra.remarks.iter())
|
||||
.filter(|remark| remark.part == part)
|
||||
.map(|remark| {
|
||||
let (x, y, _, _) = remark.anchor;
|
||||
let at = lok.place((x, y));
|
||||
let lit = self
|
||||
.active_remark
|
||||
.as_ref()
|
||||
.is_some_and(|key| key == &remark.id);
|
||||
div()
|
||||
.absolute()
|
||||
.left(at.x)
|
||||
.top(at.y)
|
||||
.size(gpui::px(14.0))
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(theme.colors.accent)
|
||||
.when(lit, |pin| pin.bg(theme.colors.accent))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
(!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user