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:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
use std::time::Instant;
use ely_gpui_component::{
layout::AppShell,
menus::MenuBar,
motion,
navigation::EditorTabs,
primitives::FocusScope,
shell::{ResizeBorder, StatusBar, TitleBar},
theme::{ActiveTheme, TextSize},
};
use gpui::{
AnyElement, AnyView, App, ElementId, FocusHandle, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div,
};
mod page;
mod panel;
mod root;
use page::Fade;
use panel::{Side, SidePanel};
pub use root::{Root, blank, document_root, root, set_document_menus, tool_root};
/// The window frame; empty panels take no room.
#[derive(IntoElement)]
pub struct Shell {
focus: FocusHandle,
title: SharedString,
tabs: Option<EditorTabs>,
left: Option<AnyView>,
right: Option<AnyView>,
status: Option<StatusBar>,
page: Option<ElementId>,
body: Vec<AnyElement>,
}
impl Shell {
pub fn new(title: impl Into<SharedString>, focus: &FocusHandle) -> Self {
Self {
focus: focus.clone(),
title: title.into(),
tabs: None,
left: None,
right: None,
status: None,
page: None,
body: Vec::new(),
}
}
pub fn tabs(mut self, tabs: EditorTabs) -> Self {
self.tabs = Some(tabs);
self
}
/// Navigation: outline, pages, folders. None leaves no strip.
pub fn left(mut self, view: Option<AnyView>) -> Self {
self.left = view;
self
}
/// The selection's properties. None leaves no strip.
pub fn right(mut self, view: Option<AnyView>) -> Self {
self.right = view;
self
}
pub fn status(mut self, bar: StatusBar) -> Self {
self.status = Some(bar);
self
}
/// The content fades in each time this key changes.
pub fn page(mut self, key: impl Into<ElementId>) -> Self {
self.page = Some(key.into());
self
}
}
#[cfg(target_os = "macos")]
fn menu_bar(_: &App) -> Option<MenuBar> {
None
}
#[cfg(not(target_os = "macos"))]
fn menu_bar(cx: &App) -> Option<MenuBar> {
cx.get_menus().map(|menus| MenuBar::new("menu-bar", menus))
}
impl ParentElement for Shell {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for Shell {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let length = motion::duration(motion::BASE, cx);
let content = div()
.flex_1()
.min_w_0()
.h_full()
.flex()
.flex_col()
.children(self.body);
let content = match self.page {
Some(page) => {
let now = Instant::now();
let state =
window.use_keyed_state("page-fade", cx, |_, _| Fade::new(page.clone(), now));
let (opacity, running) = state.update(cx, |fade, _| {
fade.show(&page, now);
(fade.opacity(now, length), fade.running(now, length))
});
if running {
window.request_animation_frame();
}
content.opacity(opacity).into_any_element()
}
None => content.into_any_element(),
};
let header = div()
.flex()
.flex_col()
.child(TitleBar::new("titlebar").title(self.title))
.children(menu_bar(cx))
.children(self.tabs);
// Panels give way so the content keeps its room.
let (left, right) = (panel::fitted(Side::Left, window, cx), panel::fitted(Side::Right, window, cx));
let (shown_left, shown_right) = panel::room(
window.viewport_size().width,
self.left.as_ref().map(|_| left),
self.right.as_ref().map(|_| right),
);
let row = div().flex_1().min_h_0().flex().child(content).child(SidePanel::new(
"right-panel",
Side::Right,
self.right.filter(|_| shown_right.is_some()),
shown_right.unwrap_or(right),
));
let frame = AppShell::new()
.title_bar(header)
.sidebar(SidePanel::new(
"left-panel",
Side::Left,
self.left.filter(|_| shown_left.is_some()),
shown_left.unwrap_or(left),
))
.child(row);
let frame = match self.status {
Some(bar) => frame.status_bar(bar),
None => frame,
};
let theme = cx.theme();
ResizeBorder::new().child(
FocusScope::new(&self.focus)
.root()
.size_full()
.font_family(theme.font_family.clone())
.text_size(theme.text_size(TextSize::Base))
.child(frame),
)
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::time::{Duration, Instant};
use ely_gpui_component::motion;
use gpui::ElementId;
/// The current page and when its fade-in began.
pub(super) struct Fade {
page: ElementId,
since: Instant,
}
impl Fade {
pub(super) fn new(page: ElementId, now: Instant) -> Self {
Self { page, since: now }
}
/// Restarts the fade when the page changes.
pub(super) fn show(&mut self, page: &ElementId, now: Instant) {
if self.page != *page {
self.page = page.clone();
self.since = now;
}
}
pub(super) fn opacity(&self, now: Instant, length: Duration) -> f32 {
let elapsed = now.saturating_duration_since(self.since).as_secs_f32();
let t = (elapsed / length.as_secs_f32().max(f32::EPSILON)).min(1.0);
0.4 + 0.6 * motion::ease_out_cubic(t)
}
pub(super) fn running(&self, now: Instant, length: Duration) -> bool {
now.saturating_duration_since(self.since) < length
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use gpui::ElementId;
use super::Fade;
const LENGTH: Duration = Duration::from_millis(200);
fn after(start: Instant, millis: u64) -> Instant {
start + Duration::from_millis(millis)
}
#[test]
fn a_fade_rises_from_dim_to_full_and_stays() {
let t0 = Instant::now();
let fade = Fade::new("a".into(), t0);
let first: Vec<f32> = (0..=20)
.map(|i| fade.opacity(after(t0, i * 10), LENGTH))
.collect();
assert_eq!(first[0], 0.4);
assert!(first.windows(2).all(|pair| pair[1] >= pair[0]), "{first:?}");
assert_eq!(first[20], 1.0);
assert!(!fade.running(after(t0, 200), LENGTH));
assert_eq!(fade.opacity(after(t0, 900), LENGTH), 1.0);
}
#[test]
fn the_same_page_keeps_its_fade() {
let t0 = Instant::now();
let mut fade = Fade::new("a".into(), t0);
let page = ElementId::Name("a".into());
fade.show(&page, after(t0, 150));
assert_eq!(fade.opacity(after(t0, 200), LENGTH), 1.0);
}
#[test]
fn a_new_page_restarts_dim() {
let t0 = Instant::now();
let mut fade = Fade::new("a".into(), t0);
assert_eq!(fade.opacity(after(t0, 500), LENGTH), 1.0);
fade.show(&"b".into(), after(t0, 500));
assert_eq!(fade.opacity(after(t0, 500), LENGTH), 0.4);
assert_eq!(fade.opacity(after(t0, 700), LENGTH), 1.0);
}
#[test]
fn reduced_motion_shows_the_page_at_once() {
let t0 = Instant::now();
let fade = Fade::new("a".into(), t0);
let length = Duration::from_millis(1);
assert_eq!(fade.opacity(after(t0, 1), length), 1.0);
assert!(!fade.running(after(t0, 1), length));
}
}
+321
View File
@@ -0,0 +1,321 @@
use std::time::{Duration, Instant};
use std::{cell::Cell, ops::Not as _, rc::Rc};
use ely_gpui_component::{layout::resize_edge, motion, theme::ActiveTheme};
use gpui::{
AnyView, App, AppContext as _, Axis, DragMoveEvent, ElementId, EmptyView, InteractiveElement as _, IntoElement,
ParentElement, Pixels, RenderOnce, StatefulInteractiveElement as _, Styled, Window, div, px,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Side {
Left,
Right,
}
/// Openness of a panel; mid-flight flips never jump.
#[derive(Clone, Copy, Debug)]
struct Slide {
from: f32,
to: f32,
since: Instant,
}
impl Slide {
fn new(open: bool, now: Instant) -> Self {
let share = if open { 1.0 } else { 0.0 };
Self {
from: share,
to: share,
since: now,
}
}
fn share(&self, now: Instant, length: Duration) -> f32 {
let elapsed = now.saturating_duration_since(self.since).as_secs_f32();
let t = (elapsed / length.as_secs_f32().max(f32::EPSILON)).min(1.0);
self.from + (self.to - self.from) * motion::ease_in_out_cubic(t)
}
fn aim(&mut self, open: bool, now: Instant, length: Duration) {
let to = if open { 1.0 } else { 0.0 };
if to != self.to {
*self = Self {
from: self.share(now, length),
to,
since: now,
};
}
}
fn running(&self, now: Instant, length: Duration) -> bool {
self.from != self.to && now.saturating_duration_since(self.since) < length
}
}
/// The slide, plus the view kept while closing.
struct Slot {
slide: Slide,
view: Option<AnyView>,
}
/// A sliding side column; no view, no room.
#[derive(IntoElement)]
pub(super) struct SidePanel {
id: ElementId,
side: Side,
view: Option<AnyView>,
width: Pixels,
}
impl SidePanel {
/// `width` is what the shell left; no view, no room.
pub(super) fn new(id: impl Into<ElementId>, side: Side, view: Option<AnyView>, width: Pixels) -> Self {
Self {
id: id.into(),
side,
view,
width,
}
}
}
impl RenderOnce for SidePanel {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let open = self.view.is_some();
let length = motion::duration(motion::BASE, cx);
let now = Instant::now();
let slot = window.use_keyed_state(self.id.clone(), cx, |_, _| Slot {
slide: Slide::new(open, now),
view: None,
});
let (share, running, view) = slot.update(cx, |slot, _| {
slot.slide.aim(open, now, length);
if open {
slot.view = self.view;
}
let share = slot.slide.share(now, length);
if !open && !slot.slide.running(now, length) {
slot.view = None;
}
(share, slot.slide.running(now, length), slot.view.clone())
});
let Some(view) = view else {
return div().into_any_element();
};
if running {
window.request_animation_frame();
}
let full = self.width;
let shown = full * share;
let theme = cx.theme();
let inner = div()
.w(full)
.h_full()
.flex_none()
.border_color(theme.colors.border)
.child(view);
let inner = match self.side {
Side::Left => inner.border_r_1().ml(shown - full),
Side::Right => inner.border_l_1(),
};
// The inner edge drags the width.
let grip = resize_edge((self.id.clone(), "edge"), Axis::Horizontal, cx)
.absolute()
.top_0()
.bottom_0()
.on_drag(Grab { side: self.side, start: full, anchor: Rc::new(Cell::new(None)) }, |_, _, _, cx| {
cx.new(|_| EmptyView)
});
let grip = match self.side {
Side::Left => grip.right_0(),
Side::Right => grip.left_0(),
};
let side = self.side;
div()
.id(self.id)
.relative()
.flex_none()
.h_full()
.w(shown)
.overflow_hidden()
.bg(theme.colors.bg)
.on_drag_move(move |event: &DragMoveEvent<Grab>, window, cx| {
let grab = event.drag(cx);
if grab.side != side {
return;
}
let at = event.event.position.x;
let anchor = grab.anchor.get().unwrap_or_else(|| {
grab.anchor.set(Some(at));
at
});
let moved = if side == Side::Left { at - anchor } else { anchor - at };
let wanted = clamp(side, grab.start + moved, window);
crate::settings::panels::set_width(side == Side::Left, wanted, cx);
window.refresh();
})
.child(inner)
.children(running.not().then_some(grip))
.into_any_element()
}
}
/// A drag on a panel's edge, from the pressed width.
struct Grab {
side: Side,
start: Pixels,
anchor: Rc<Cell<Option<Pixels>>>,
}
/// Defaults leave the page most of the window.
fn default_width(side: Side) -> Pixels {
match side {
Side::Left => px(220.0),
// A name column and an editor beside it.
Side::Right => px(320.0),
}
}
/// The user's width, else the default, within bounds.
pub(super) fn fitted(side: Side, window: &Window, cx: &App) -> Pixels {
let chosen = crate::settings::panels::width(side == Side::Left, cx);
clamp(side, chosen.unwrap_or(default_width(side)), window)
}
/// The narrowest a panel works: a list, or properties.
fn least(side: Side) -> Pixels {
match side {
Side::Left => px(160.0),
Side::Right => px(280.0),
}
}
/// At least its least, under half the window.
fn clamp(side: Side, wanted: Pixels, window: &Window) -> Pixels {
let most = (window.viewport_size().width * 0.45).max(least(side));
wanted.clamp(least(side), most)
}
/// The content keeps this much; panels give way first.
const CONTENT_LEAST: f32 = 360.0;
/// Shown widths: shrink both, then hide right, then left.
pub(super) fn room(window: Pixels, left: Option<Pixels>, right: Option<Pixels>) -> (Option<Pixels>, Option<Pixels>) {
let spare = window - px(CONTENT_LEAST);
if let (Some(l), Some(r)) = (left, right) {
let r = r.min((spare - l).max(least(Side::Right)));
let l = l.min((spare - r).max(least(Side::Left)));
if l + r <= spare {
return (Some(l), Some(r));
}
return (alone(Side::Left, l.max(left.unwrap_or(l)), spare), None);
}
(left.and_then(|l| alone(Side::Left, l, spare)), right.and_then(|r| alone(Side::Right, r, spare)))
}
/// One panel in `spare`, if its least fits.
fn alone(side: Side, wanted: Pixels, spare: Pixels) -> Option<Pixels> {
let shown = wanted.min(spare);
(shown >= least(side)).then_some(shown)
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use gpui::px;
use super::{Slide, room};
const LENGTH: Duration = Duration::from_millis(200);
fn after(start: Instant, millis: u64) -> Instant {
start + Duration::from_millis(millis)
}
#[test]
fn a_panel_starts_where_it_is_aimed_and_stays() {
let t0 = Instant::now();
assert_eq!(Slide::new(true, t0).share(after(t0, 5000), LENGTH), 1.0);
assert_eq!(Slide::new(false, t0).share(after(t0, 5000), LENGTH), 0.0);
assert!(!Slide::new(true, t0).running(t0, LENGTH));
}
#[test]
fn opening_rises_without_stepping_back_and_ends_at_one() {
let t0 = Instant::now();
let mut slide = Slide::new(false, t0);
slide.aim(true, t0, LENGTH);
let shares: Vec<f32> = (0..=20)
.map(|i| slide.share(after(t0, i * 10), LENGTH))
.collect();
assert_eq!(shares[0], 0.0);
assert_eq!(shares[20], 1.0);
assert!(
shares.windows(2).all(|pair| pair[1] >= pair[0]),
"{shares:?}"
);
assert!(slide.running(after(t0, 100), LENGTH));
assert!(!slide.running(after(t0, 200), LENGTH));
assert_eq!(slide.share(after(t0, 900), LENGTH), 1.0);
}
#[test]
fn flipping_the_aim_mid_flight_continues_from_where_it_was() {
let t0 = Instant::now();
let mut slide = Slide::new(false, t0);
slide.aim(true, t0, LENGTH);
let mid = after(t0, 80);
let before = slide.share(mid, LENGTH);
assert!(before > 0.0 && before < 1.0);
slide.aim(false, mid, LENGTH);
assert_eq!(slide.share(mid, LENGTH), before);
assert!(slide.share(after(mid, 40), LENGTH) < before);
assert_eq!(slide.share(after(mid, 400), LENGTH), 0.0);
}
#[test]
fn aiming_again_at_the_same_target_does_not_restart() {
let t0 = Instant::now();
let mut slide = Slide::new(false, t0);
slide.aim(true, t0, LENGTH);
let at = after(t0, 120);
let share = slide.share(at, LENGTH);
slide.aim(true, at, LENGTH);
assert_eq!(slide.share(at, LENGTH), share);
assert_eq!(slide.share(after(t0, 200), LENGTH), 1.0);
}
#[test]
fn reduced_motion_settles_in_a_millisecond() {
let t0 = Instant::now();
let mut slide = Slide::new(false, t0);
slide.aim(true, t0, Duration::from_millis(1));
assert_eq!(slide.share(after(t0, 1), Duration::from_millis(1)), 1.0);
assert!(!slide.running(after(t0, 1), Duration::from_millis(1)));
}
#[test]
fn panels_give_way_so_the_content_keeps_its_room() {
let (left, right) = (Some(px(220.0)), Some(px(320.0)));
assert_eq!(room(px(1100.0), left, right), (left, right), "wide: as chosen");
// 800 leaves 440: both shrink to their least.
assert_eq!(room(px(800.0), left, right), (Some(px(160.0)), Some(px(280.0))));
// 640 leaves 280: the right goes, the left stays.
assert_eq!(room(px(640.0), left, right), (left, None));
// A wider left alone shrinks to the spare room.
assert_eq!(room(px(640.0), Some(px(400.0)), None), (Some(px(280.0)), None));
// Too narrow for even the least: hidden.
assert_eq!(room(px(500.0), left, None), (None, None));
assert_eq!(room(px(640.0), None, right), (None, Some(px(280.0))));
}
}
+302
View File
@@ -0,0 +1,302 @@
use ely_gpui_component::{
feedback::{Toast, ToastViewport, Toaster},
navigation::{Command as PaletteCommand, CommandPalette},
primitives::{Severity, raise},
};
use ely_platform::AppId;
use std::path::{Path, PathBuf};
use gpui::{
App, AppContext as _, Context, Entity, FocusHandle, InteractiveElement as _, IntoElement,
Keystroke, ParentElement, Render, SharedString, Styled as _, Window, div,
prelude::FluentBuilder as _,
};
use super::Shell;
use crate::commands::{
self, Export, New, Open, OpenElyAgent, OpenElyDoc, OpenElyMail, OpenElyMark, OpenElyPdf,
OpenElyShow, OpenElyTable, OpenElyTree, Print, Save, SaveAs, ToggleCommandPalette,
};
/// The shared root: shell, file commands, command palette.
pub struct Root {
app: AppId,
focus: FocusHandle,
palette: bool,
recent: Vec<SharedString>,
last: Option<SharedString>,
toaster: Entity<Toaster>,
content: Option<gpui::AnyView>,
document: bool,
file: &'static [&'static commands::Command],
}
/// A window with only its frame.
pub fn blank(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
root(app, None, window, cx)
}
pub fn root(
app: AppId,
content: Option<gpui::AnyView>,
window: &mut Window,
cx: &mut App,
) -> Entity<Root> {
build(app, content, false, &commands::FILE, Vec::new(), window, cx)
}
/// A tool window: File has New and Open; `menus` follow.
pub fn tool_root(
app: AppId,
content: gpui::AnyView,
menus: Vec<gpui::Menu>,
window: &mut Window,
cx: &mut App,
) -> Entity<Root> {
build(
app,
Some(content),
false,
&commands::OPENING,
menus,
window,
cx,
)
}
/// A document window: File holds `file`; `menus` follow.
pub fn document_root(
app: AppId,
document: gpui::AnyView,
file: &'static [&'static commands::Command],
menus: Vec<gpui::Menu>,
window: &mut Window,
cx: &mut App,
) -> Entity<Root> {
build(app, Some(document), true, file, menus, window, cx)
}
/// Replaces a document window's menus.
pub fn set_document_menus(
app: AppId,
file: &'static [&'static commands::Command],
menus: Vec<gpui::Menu>,
cx: &mut App,
) {
cx.set_menus(commands::menus(app, file, true, menus, cx));
}
fn build(
app: AppId,
content: Option<gpui::AnyView>,
document: bool,
file: &'static [&'static commands::Command],
menus: Vec<gpui::Menu>,
window: &mut Window,
cx: &mut App,
) -> Entity<Root> {
cx.set_menus(commands::menus(app, file, document, menus, cx));
cx.new(|cx| {
let focus = cx.focus_handle();
// A document takes focus itself.
if !document {
window.focus(&focus, cx);
}
Root {
app,
focus,
palette: false,
recent: Vec::new(),
last: None,
toaster: cx.new(|_| Toaster::default()),
content,
document,
file,
}
})
}
impl Root {
pub fn palette_open(&self) -> bool {
self.palette
}
pub fn recent(&self) -> &[SharedString] {
&self.recent
}
pub fn last(&self) -> Option<&SharedString> {
self.last.as_ref()
}
fn attempt(&mut self, id: &'static str, cx: &mut Context<Self>) {
if let Some(reason) = commands::blocked(id, self.document, cx) {
log::info!("command: {id} blocked: {reason}");
self.last = Some(format!("{id} blocked").into());
self.toaster.update(cx, |toaster, cx| {
toaster.push(Toast::new(reason).severity(Severity::Warning), cx);
});
cx.notify();
return;
}
self.ran(id, cx);
}
/// Shows `text` in the window's toaster.
pub fn toast(
&mut self,
text: impl Into<SharedString>,
severity: Severity,
cx: &mut Context<Self>,
) {
self.toaster.update(cx, |toaster, cx| {
toaster.push(Toast::new(text.into()).severity(severity), cx);
});
}
/// A toast whose button opens the output `path` (§16).
pub fn toast_output(
&mut self,
text: impl Into<SharedString>,
severity: Severity,
path: PathBuf,
cx: &mut Context<Self>,
) {
let root = cx.weak_entity();
let open = crate::i18n::text(cx, "toast.open", &[]);
let toast = Toast::new(text.into())
.severity(severity)
.action(open, move |_, cx| {
let (root, path) = (root.clone(), path.clone());
// Opened off the UI thread; a failure comes back.
cx.spawn(async move |cx| {
let opened = cx.background_spawn(async move { view(&path) }).await;
if let Err(error) = opened {
log::error!("apps: the output did not open: {error}");
cx.update(|cx| {
let said = crate::i18n::text(cx, "toast.open_failed", &[("error", &error.to_string())]);
root.update(cx, |root, cx| root.toast(said, Severity::Danger, cx)).ok();
});
}
})
.detach();
});
self.toaster.update(cx, |toaster, cx| {
toaster.push(toast, cx);
});
}
/// Starts another Ely app; a failure says why.
fn switch(&mut self, app: AppId, cx: &mut Context<Self>) {
cx.spawn(async move |root, cx| {
let opened = cx.background_spawn(async move { ely_platform::apps::open(app, &[]) }).await;
if let Err(error) = opened {
log::error!("apps: {} did not open: {error}", app.name());
root.update(cx, |root, cx| {
let said = crate::i18n::text(cx, "menu.app.switch_failed", &[("app", app.name()), ("error", &error.to_string())]);
root.toast(said, Severity::Danger, cx);
})
.ok();
}
})
.detach();
}
pub fn close_palette(&mut self, cx: &mut Context<Self>) {
self.palette = false;
cx.notify();
}
fn ran(&mut self, id: &'static str, cx: &mut Context<Self>) {
log::info!("command: {id} ran in {}", self.app.name());
self.last = Some(id.into());
self.recent.retain(|value| value != id);
self.recent.insert(0, id.into());
self.recent.truncate(5);
cx.notify();
}
}
impl Render for Root {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let this = cx.entity();
let body = match &self.content {
Some(view) => view.clone().into_any_element(),
None => Shell::new(self.app.name(), &self.focus).into_any_element(),
};
let has = |id| self.file.iter().any(|command| command.id == id);
let frame = div()
.size_full()
.key_context(commands::CONTEXT)
.on_action(cx.listener(|this, _: &New, _, cx| this.attempt("file.new", cx)))
.on_action(cx.listener(|this, _: &Open, _, cx| this.attempt("file.open", cx)))
.when(has("file.save"), |frame| {
frame
.on_action(cx.listener(|this, _: &Save, _, cx| this.attempt("file.save", cx)))
.on_action(
cx.listener(|this, _: &SaveAs, _, cx| this.attempt("file.save_as", cx)),
)
.on_action(
cx.listener(|this, _: &Export, _, cx| this.attempt("file.export", cx)),
)
.on_action(cx.listener(|this, _: &Print, _, cx| this.attempt("file.print", cx)))
})
.on_action(cx.listener(|this, _: &OpenElyDoc, _, cx| this.switch(AppId::Doc, cx)))
.on_action(cx.listener(|this, _: &OpenElyTable, _, cx| this.switch(AppId::Table, cx)))
.on_action(cx.listener(|this, _: &OpenElyShow, _, cx| this.switch(AppId::Show, cx)))
.on_action(cx.listener(|this, _: &OpenElyPdf, _, cx| this.switch(AppId::Pdf, cx)))
.on_action(cx.listener(|this, _: &OpenElyMail, _, cx| this.switch(AppId::Mail, cx)))
.on_action(cx.listener(|this, _: &OpenElyTree, _, cx| this.switch(AppId::Tree, cx)))
.on_action(cx.listener(|this, _: &OpenElyMark, _, cx| this.switch(AppId::Mark, cx)))
.on_action(cx.listener(|this, _: &OpenElyAgent, _, cx| this.switch(AppId::Agent, cx)))
.on_action(cx.listener(|this, _: &ToggleCommandPalette, window, cx| {
if this.palette {
window.on_next_frame(|window, cx| {
window.dispatch_keystroke(
Keystroke {
key: "escape".into(),
..Default::default()
},
cx,
);
});
} else {
this.palette = true;
cx.notify();
}
}))
.child(body);
let frame = frame.child(raise(
"toasts",
ToastViewport::new("toast-viewport", &self.toaster),
));
if !self.palette {
return frame.into_any_element();
}
let palette = CommandPalette::new("command-palette", move |_, cx| {
this.update(cx, |this, cx| this.close_palette(cx));
})
.group(
crate::i18n::text(cx, "palette.group.file", &[]),
self.file.iter().map(|c| {
PaletteCommand::new(
c.id,
crate::i18n::text(cx, &format!("command.{}", c.id), &[]),
)
.keys(c.keys)
}),
)
.recent(self.recent.clone())
.on_run(move |value, window, cx| commands::run(value, window, cx));
frame.child(raise("palette", palette)).into_any_element()
}
}
/// PDFs open in ElyPDF if installed; others, the system's app.
fn view(path: &Path) -> std::io::Result<()> {
let pdf = path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"));
if pdf && ely_platform::apps::installed(AppId::Pdf) {
return ely_platform::apps::open(AppId::Pdf, &[path.to_path_buf()]);
}
ely_platform::apps::open_default(path)
}