Files
ElyOffice/crates/ely-ui/examples/shell/main.rs
T
ZacharyZhang-NY b7fca0146d 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.
2026-10-08 21:20:08 -04:00

480 lines
15 KiB
Rust

//! Every shell region; page two drops the right panel.
mod dialogs;
mod probe;
mod props;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use dialogs::Overlay;
use ely_gpui_component::{
feedback::EmptyState,
forms::TextInput,
navigation::{EditorTab, EditorTabs},
primitives::{FocusRing as _, IconName},
shell::{StatusBar, StatusBarItem},
theme::ActiveTheme,
};
use ely_platform::AppId;
use ely_ui::{LongTask, Root, Shell, TaskState};
use gpui::{
App, AppContext as _, Bounds, Context, Entity, FocusHandle, InteractiveElement as _,
IntoElement, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement as _,
Styled, Window, canvas, div, prelude::FluentBuilder as _, px,
};
use props::Props;
fn main() {
ely_ui::run(AppId::Doc, gallery);
}
fn gallery(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let geometry = Rc::new(RefCell::new(None));
let view = cx.new(|cx| Gallery::new(window, cx, geometry));
let root = ely_ui::root(app, Some(view.clone().into()), window, cx);
if std::env::var("ELY_GALLERY_PROBE").as_deref() == Ok("1") {
let next = (root.clone(), view.clone());
window.on_next_frame(move |window, cx| probe::run(next.0, next.1, 0, window, cx));
}
root
}
pub(crate) struct DemoPage {
id: &'static str,
title: SharedString,
format: &'static str,
}
fn page(id: &'static str, title: &'static str, format: &'static str) -> DemoPage {
DemoPage {
id,
title: title.into(),
format,
}
}
pub(crate) struct Gallery {
focus: FocusHandle,
page: SharedString,
pages: Vec<DemoPage>,
selection: Vec<usize>,
dialog: Option<Overlay>,
task: Option<Entity<LongTask>>,
beats_began: Arc<AtomicUsize>,
chip: FocusHandle,
until: Option<Instant>,
frames: u8,
probe_focused: Option<FocusHandle>,
geometry: Rc<RefCell<Option<Bounds<Pixels>>>>,
content: Entity<Content>,
nav: Entity<Nav>,
props: Entity<Props>,
}
impl Gallery {
fn new(
window: &mut Window,
cx: &mut Context<Self>,
geometry: Rc<RefCell<Option<Bounds<Pixels>>>>,
) -> Self {
let focus = cx.focus_handle();
window.focus(&focus, cx);
let me = cx.weak_entity();
Gallery {
focus,
page: "one".into(),
pages: vec![
page("one", "Page One", "Text"),
page("two", "Page Two", "Grid"),
],
selection: vec![0],
dialog: None,
task: None,
beats_began: Arc::new(AtomicUsize::new(0)),
chip: cx.focus_handle().tab_index(4).tab_stop(true),
until: None,
frames: 0,
probe_focused: None,
geometry: geometry.clone(),
content: cx.new(|_| Content {
page: "one".into(),
anchor: None,
geometry,
}),
nav: cx.new(|cx| Nav {
anchor: cx.focus_handle().tab_index(1).tab_stop(true),
}),
props: cx.new(|cx| Props {
anchor: cx.focus_handle().tab_index(3).tab_stop(true),
gallery: me.clone(),
rows: Vec::new(),
empty: false,
}),
}
}
pub(crate) fn props_rows(&self, cx: &App) -> Vec<(String, String)> {
let pages = ely_ui::i18n::text(cx, "gallery.row.pages", &[]).to_string();
if self.selection.is_empty() {
return vec![(pages, "0".into())];
}
let formats: Vec<&str> = self
.selection
.iter()
.map(|&index| self.pages[index].format)
.collect();
let same = formats.windows(2).all(|pair| pair[0] == pair[1]);
let format = if same {
formats[0].to_string()
} else {
ely_ui::i18n::text(cx, "gallery.mixed", &[]).to_string()
};
vec![
(
ely_ui::i18n::text(cx, "gallery.row.format", &[]).to_string(),
format,
),
(pages, self.selection.len().to_string()),
]
}
pub(crate) fn page_titles(&self) -> Vec<String> {
self.pages
.iter()
.map(|page| page.title.to_string())
.collect()
}
pub(crate) fn open_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(&index) = self.selection.first() else {
log::info!("gallery: rename refused: no page is selected");
return;
};
let title = self.pages[index].title.to_string();
let field = cx.new(|cx| {
TextInput::new(window, cx).placeholder(ely_ui::i18n::text(
cx,
"gallery.rename.placeholder",
&[],
))
});
field.update(cx, |field, cx| field.set_text(title, cx));
self.dialog = Some(Overlay::Rename { field });
cx.notify();
}
pub(crate) fn rename(&mut self, text: &str, cx: &mut Context<Self>) {
let title: SharedString = text.trim().to_string().into();
log::info!("gallery: page renamed to {title}");
self.pages[self.selection[0]].title = title;
self.dialog = None;
cx.notify();
}
pub(crate) fn open_delete(&mut self, cx: &mut Context<Self>) {
if self.selection.is_empty() {
log::info!("gallery: delete refused: no page is selected");
return;
}
self.dialog = Some(Overlay::Delete);
cx.notify();
}
pub(crate) fn delete_confirmed(&mut self, cx: &mut Context<Self>) {
let Some(&index) = self.selection.first() else {
log::info!("gallery: delete refused: no page is selected");
self.dialog = None;
cx.notify();
return;
};
let removed = self.pages.remove(index);
log::info!("gallery: {} deleted", removed.title);
self.selection = if self.pages.is_empty() {
Vec::new()
} else {
vec![0]
};
if self.pages.iter().any(|page| page.id == self.page.as_ref()) {
} else if let Some(first) = self.pages.first() {
self.page = first.id.into();
} else {
self.page = "".into();
}
self.dialog = None;
cx.notify();
}
pub(crate) fn close_dialog(&mut self, cx: &mut Context<Self>) {
self.dialog = None;
cx.notify();
}
pub(crate) fn start_task(&mut self, fail: bool, cx: &mut Context<Self>) {
let blown = Arc::new(AtomicBool::new(false));
let began = self.beats_began.clone();
began.store(0, Ordering::Relaxed);
let disk_full = ely_ui::i18n::text(cx, "gallery.error.disk_full", &[]);
self.task = Some(LongTask::start(
"export",
4,
move |done, stop| {
began.fetch_add(1, Ordering::Relaxed);
if fail && done == 3 {
std::thread::sleep(Duration::from_millis(400));
if !blown.swap(true, Ordering::Relaxed) {
return Err(disk_full.clone());
}
return Ok(());
}
for _ in 0..12 {
if stop.load(Ordering::Relaxed) {
return Ok(());
}
std::thread::sleep(Duration::from_millis(10));
}
Ok(())
},
cx,
));
cx.notify();
}
pub(crate) fn beats_began(&self) -> usize {
self.beats_began.load(Ordering::Relaxed)
}
pub(crate) fn clicked_task(&mut self, cx: &mut Context<Self>) {
if let Some(task) = &self.task {
match task.read(cx).state() {
TaskState::Running { .. } => task.update(cx, |task, cx| task.cancel(cx)),
TaskState::Failed(_) => task.update(cx, |task, cx| task.retry(cx)),
_ => {}
}
}
}
pub(crate) fn task_state(&self, cx: &App) -> Option<String> {
self.task.as_ref().map(|task| match task.read(cx).state() {
TaskState::Running { done, total } => format!("Running {done}/{total}"),
TaskState::Succeeded => "Succeeded".into(),
TaskState::Failed(error) => format!("Failed({error})"),
TaskState::Cancelled => "Cancelled".into(),
})
}
}
impl Render for Gallery {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let page = self.page.clone();
let right = (page.as_ref() == "one").then(|| self.props.clone().into());
let rows = self.props_rows(cx);
self.props.update(cx, |props, _| {
props.rows = rows;
props.empty = self.pages.is_empty();
});
self.content
.update(cx, |content, _| content.page = page.clone());
let task_label = self.task_state(cx).map(|state| match state.as_str() {
"Succeeded" => ely_ui::i18n::text(cx, "gallery.task.done", &[]),
"Cancelled" => ely_ui::i18n::text(cx, "gallery.task.cancelled", &[]),
_ if state.starts_with("Failed(") => ely_ui::i18n::text(
cx,
"gallery.task.retry",
&[(
"error",
state.trim_start_matches("Failed(").trim_end_matches(")"),
)],
),
_ => ely_ui::i18n::text(
cx,
"gallery.task.progress",
&[("state", state.trim_start_matches("Running "))],
),
});
let this = cx.entity();
let theme = cx.theme();
let status = StatusBar::new()
.when_some(task_label, |bar, label| {
bar.left(
div()
.id("task-chip")
.track_focus(&self.chip)
.role(gpui::Role::Button)
.aria_label(label.clone())
.flex()
.items_center()
.h_full()
.px_1p5()
.rounded(theme.radius(ely_gpui_component::theme::Radius::Sm))
.focus_ring(cx)
.hover(|style| style.bg(theme.colors.hover))
.on_click(move |_, _, cx| {
this.update(cx, |this, cx| this.clicked_task(cx));
})
.child(label),
)
})
.left(StatusBarItem::new("state").label(ely_ui::i18n::text(cx, "gallery.ready", &[])))
.right(StatusBarItem::new("page").label(page.clone()));
let body: gpui::AnyElement = if self.pages.is_empty() {
EmptyState::new(
"no-pages",
IconName::File,
ely_ui::i18n::text(cx, "gallery.empty.pages", &[]),
)
.into_any_element()
} else {
self.content.clone().into_any_element()
};
let shell = Shell::new("Shell Gallery", &self.focus)
.left(Some(self.nav.clone().into()))
.right(right)
.status(status)
.page(page.clone())
.child(body);
let shell = if self.pages.is_empty() {
shell
} else {
shell.tabs(
EditorTabs::new(
"tabs",
self.pages
.iter()
.map(|page| EditorTab::new(page.id, page.title.clone()))
.collect::<Vec<_>>(),
)
.selected(page.clone())
.on_select(cx.listener(|this, id: &SharedString, _, cx| {
this.page = id.clone();
this.selection = vec![
this.pages
.iter()
.position(|page| page.id == id.as_ref())
.unwrap_or(0),
];
cx.notify();
})),
)
};
let overlay = match &self.dialog {
Some(Overlay::Rename { field }) => Some(dialogs::rename_overlay(field.clone(), cx)),
Some(Overlay::Delete) => Some(dialogs::delete_overlay(
self.pages[self.selection[0]].title.clone(),
cx,
)),
None => None,
};
div()
.size_full()
.child(shell)
.when_some(overlay, |layer, dialog| layer.child(dialog))
}
}
struct Content {
page: SharedString,
anchor: Option<FocusHandle>,
geometry: Rc<RefCell<Option<Bounds<Pixels>>>>,
}
impl Render for Content {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let handle = window
.use_keyed_state("content-anchor", cx, |_, cx| {
cx.focus_handle().tab_index(2).tab_stop(true)
})
.read(cx)
.clone();
self.anchor = Some(handle.clone());
let geometry = self.geometry.clone();
div()
.flex_1()
.relative()
.child(
canvas(
move |bounds, _, _| {
*geometry.borrow_mut() = Some(bounds);
},
|_, _, _, _| {},
)
.absolute()
.inset_0(),
)
.child(
div()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.child(anchor(
"content-anchor",
ely_ui::i18n::text(cx, "gallery.content", &[]),
&handle,
cx,
))
.child(ely_ui::i18n::text(
cx,
"gallery.content_of",
&[("page", self.page.as_ref())],
)),
)
}
}
fn close(a: Pixels, b: Pixels) -> bool {
(a - b).abs() <= px(2.0)
}
fn anchor(
id: &'static str,
label: impl Into<SharedString>,
handle: &FocusHandle,
cx: &mut App,
) -> impl IntoElement {
let theme = cx.theme();
let label = label.into();
div()
.id(id)
.track_focus(handle)
.tab_index(0)
.role(gpui::Role::Button)
.aria_label(label.clone())
.border_1()
.border_color(theme.colors.border)
.rounded(theme.radius(ely_gpui_component::theme::Radius::Sm))
.px_2()
.py_1()
.focus_ring(cx)
.child(label)
}
struct Nav {
anchor: FocusHandle,
}
impl Render for Nav {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.h_full()
.p_2()
.flex()
.flex_col()
.gap_1()
.text_color(theme.colors.fg)
.child(ely_ui::i18n::text(cx, "gallery.nav", &[]))
.child(anchor(
"nav-anchor",
ely_ui::i18n::text(cx, "gallery.inbox", &[]),
&self.anchor,
cx,
))
.child(ely_ui::i18n::text(cx, "gallery.archive", &[]))
}
}