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,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user