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

+65
View File
@@ -0,0 +1,65 @@
use ely_gpui_component::{
forms::TextInput,
overlays::{ConfirmDialog, PromptDialog},
};
use gpui::{Context, Entity, IntoElement, SharedString};
use crate::Gallery;
pub(crate) enum Overlay {
Rename { field: Entity<TextInput> },
Delete,
}
fn rename_check(empty: &str, failed: &str, text: &str) -> Result<(), SharedString> {
match text.trim() {
"" => Err(empty.into()),
"fail" => Err(failed.into()),
_ => Ok(()),
}
}
pub(crate) fn rename_overlay(
field: Entity<TextInput>,
cx: &mut Context<Gallery>,
) -> gpui::AnyElement {
let this = cx.entity();
PromptDialog::new(
"rename-dialog",
ely_ui::i18n::text(cx, "gallery.rename.title", &[]),
&field,
{
let this = this.clone();
move |_, cx| this.update(cx, |this, cx| this.close_dialog(cx))
},
)
.submit(ely_ui::i18n::text(cx, "gallery.rename.submit", &[]))
.check({
let empty = ely_ui::i18n::text(cx, "gallery.rename.empty", &[]);
let failed = ely_ui::i18n::text(cx, "gallery.rename.failed", &[]);
move |text| rename_check(&empty, &failed, text)
})
.on_submit(move |text, _, cx| {
this.update(cx, |this, cx| this.rename(text, cx));
})
.into_any_element()
}
pub(crate) fn delete_overlay(title: SharedString, cx: &mut Context<Gallery>) -> gpui::AnyElement {
let this = cx.entity();
ConfirmDialog::new(
"delete-dialog",
ely_ui::i18n::text(cx, "gallery.delete.title", &[("title", &title)]),
ely_ui::i18n::text(cx, "gallery.delete.message", &[("title", &title)]),
{
let this = this.clone();
move |_, cx| this.update(cx, |this, cx| this.close_dialog(cx))
},
)
.confirm(ely_ui::i18n::text(cx, "gallery.delete.confirm", &[]))
.destructive()
.on_confirm(move |_, cx| {
this.update(cx, |this, cx| this.delete_confirmed(cx));
})
.into_any_element()
}
+479
View File
@@ -0,0 +1,479 @@
//! 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", &[]))
}
}
+497
View File
@@ -0,0 +1,497 @@
mod support;
use std::time::{Duration, Instant};
use ely_gpui_component::theme::ActiveTheme;
use ely_ui::{Root, commands};
use gpui::{App, Context, Entity, FocusHandle, Window};
use crate::Gallery;
use crate::dialogs::Overlay;
use support::{
Probe, chord, file_menu, menu_action, plain, ran_first, secondary, shift_p, type_keys,
};
pub(crate) use support::run;
impl Gallery {
fn ready(&mut self, condition: bool) -> bool {
if self.frames >= 100 || condition {
self.frames = 0;
return true;
}
self.frames += 1;
false
}
fn delete_ready(&mut self, window: &Window, cx: &App) -> bool {
let away = self.away(&self.props.read(cx).anchor, window, cx);
self.ready(away) && matches!(self.dialog, Some(Overlay::Delete))
}
fn away(&self, anchor: &FocusHandle, window: &Window, cx: &App) -> bool {
window
.focused(cx)
.map(|focus| focus != *anchor)
.unwrap_or(false)
}
fn palette_wait(
&mut self,
palette: bool,
props: &FocusHandle,
window: &Window,
cx: &App,
) -> bool {
self.ready(palette && self.away(props, window, cx))
}
pub(crate) fn probe_step(
&mut self,
root: &Entity<Root>,
step: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> Probe {
let nav = self.nav.read(cx).anchor.clone();
let props = self.props.read(cx).anchor.clone();
let anchor = self.content.read(cx).anchor.clone();
let Some(anchor) = anchor else {
panic!("the content anchor renders before the probe runs");
};
if let Some(until) = self.until {
if Instant::now() < until {
return Probe::Again;
}
self.until = None;
}
let bounds = *self.geometry.borrow();
let layout = |both: bool| match bounds {
Some(area) => {
let side = cx.theme().sidebar_width(false).to_pixels(window.rem_size());
let sheet = cx.theme().sheet_size().to_pixels(window.rem_size());
let width = window.viewport_size().width;
let panels = if both { side + sheet } else { side };
crate::close(area.origin.x, side) && crate::close(area.size.width, width - panels)
}
None => false,
};
let palette = root.read(cx).palette_open();
let which = |window: &Window| {
if nav.is_focused(window) {
"nav"
} else if anchor.is_focused(window) {
"content"
} else if props.is_focused(window) {
"props"
} else if self.chip.is_focused(window) {
"chip"
} else {
"other"
}
};
match step {
_ if std::env::var("ELY_PROBE_HOLD_AT")
.ok()
.and_then(|v| v.parse().ok())
== Some(step) =>
{
if self.frames == 0 {
log::info!("probe: hold at {step}");
self.frames = 1;
}
return Probe::Again;
}
0 => window.focus(&nav, cx),
1 => {
log::info!("probe: nav on page one shown={}", nav.is_focused(window));
log::info!("probe: layout with both panels fits={}", layout(true));
}
2 => return Probe::Keys(vec![plain("tab")]),
3 => log::info!("probe: tab 1 -> {}", which(window)),
4 => return Probe::Keys(vec![plain("tab")]),
5 => {
log::info!("probe: tab 2 -> {}", which(window));
window.focus(&props, cx);
}
6 => {
log::info!(
"probe: props on page one shown={}",
props.is_focused(window)
);
self.page = "two".into();
self.until = Some(Instant::now() + Duration::from_millis(400));
cx.notify();
}
7 => {
log::info!(
"probe: props on page two hidden={}",
!props.is_focused(window)
);
log::info!("probe: layout without right panel fits={}", layout(false));
self.probe_focused = Some(anchor.clone());
window.focus(&anchor, cx);
}
8 => {
log::info!(
"probe: content on page two shown={}",
anchor.is_focused(window)
);
self.page = "one".into();
self.until = Some(Instant::now() + Duration::from_millis(400));
cx.notify();
}
9 => {
let kept = self.probe_focused.clone();
let Some(kept) = kept else {
panic!("step 7 focused the content anchor");
};
log::info!("probe: content focus kept={}", kept.is_focused(window));
window.focus(&props, cx);
}
10 => {
log::info!(
"probe: props back on page one shown={}",
props.is_focused(window)
);
log::info!("probe: layout restored fits={}", layout(true));
let items = file_menu(cx);
log::info!("probe: menus app={} File={}", items.0, items.1.join("|"));
window.resize(gpui::size(gpui::px(800.0), gpui::px(600.0)));
self.until = Some(Instant::now() + Duration::from_millis(400));
}
11 => {
let viewport = window.viewport_size();
log::info!(
"probe: resized viewport={}x{}",
f32::from(viewport.width) as u32,
f32::from(viewport.height) as u32
);
log::info!("probe: resized layout fits={}", layout(true));
if let Some(area) = bounds {
let x: f32 = (area.origin.x + area.size.width / 2.0).into();
let y: f32 = (area.origin.y + area.size.height / 2.0).into();
return Probe::Click { x, y };
}
}
12 => {
log::info!("probe: resized click hits={}", anchor.is_focused(window));
window.resize(gpui::size(gpui::px(1100.0), gpui::px(720.0)));
self.until = Some(Instant::now() + Duration::from_millis(400));
}
13 => {
let restored = window.viewport_size();
log::info!(
"probe: restored viewport={}x{}",
f32::from(restored.width) as u32,
f32::from(restored.height) as u32
);
log::info!("probe: layout back fits={}", layout(true));
log::info!("probe: scale factor={}", window.scale_factor());
let map: Vec<String> = file_menu(cx)
.2
.into_iter()
.map(|(name, action)| format!("{name}={action}"))
.collect();
log::info!("probe: menu map {}", map.join("|"));
}
step @ 14..=37 => {
if step == 14 {
log::info!("probe: layout back fits={}", layout(true));
}
let base = if step < 26 { 14 } else { 26 };
let i = (step - base) / 2;
if (step - base) % 2 == 0 {
if base == 14 {
return Probe::Keys(vec![chord(i)]);
}
return Probe::Actions(vec![menu_action(i, cx)]);
}
log::info!(
"probe: trigger {} {} -> {}",
if base == 14 { "keys" } else { "menu" },
commands::FILE[i].id,
ran_first(root, cx)
);
}
38 => return Probe::Keys(vec![shift_p()]),
39 => {
if !self.palette_wait(palette, &props, window, cx) {
return Probe::Again;
}
log::info!("probe: palette open={palette}");
log::info!(
"probe: palette took focus={}",
self.away(&props, window, cx)
);
return Probe::Keys(type_keys("open"));
}
40 => {
log::info!("probe: palette open={palette}");
return Probe::Keys(vec![plain("enter")]);
}
41 => {
log::info!("probe: palette open={palette}");
return Probe::Keys(vec![shift_p()]);
}
42 => {
if !self.palette_wait(palette, &props, window, cx) {
return Probe::Again;
}
log::info!("probe: palette open={palette}");
return Probe::Keys(vec![shift_p()]);
}
43 => {
log::info!("probe: palette open={palette}");
log::info!(
"probe: palette toggle-off returned focus={}",
anchor.is_focused(window)
);
return Probe::Keys(vec![shift_p()]);
}
44 => {
if !self.palette_wait(palette, &props, window, cx) {
return Probe::Again;
}
return Probe::Keys(vec![plain("escape")]);
}
45 => {
let palette = root.read(cx).palette_open();
log::info!("probe: esc closed palette={}", !palette);
return Probe::Keys(vec![shift_p()]);
}
46 => {
if !self.palette_wait(palette, &props, window, cx) {
return Probe::Again;
}
return Probe::Keys(type_keys("save"));
}
47 => return Probe::Keys(vec![plain("enter")]),
48 => {
log::info!("probe: blocked save -> {}", ran_first(root, cx));
self.selection = vec![0, 1];
cx.notify();
let rows: Vec<String> = self
.props_rows(cx)
.into_iter()
.map(|(name, value)| format!("{name}={value}"))
.collect();
log::info!("probe: props rows {}", rows.join("|"));
}
49 => {
self.open_rename(window, cx);
return Probe::Keys(
type_keys("fail")
.into_iter()
.chain([plain("enter")])
.collect(),
);
}
50 => {
let Some(Overlay::Rename { field }) = &self.dialog else {
panic!("the rename dialog stays after a failed check");
};
log::info!("probe: rename open=true text={}", field.read(cx).text());
field.update(cx, |field, cx| field.set_text("Appendix", cx));
return Probe::Keys(vec![plain("enter")]);
}
51 => {
log::info!("probe: renamed title={}", self.page_titles().join("|"));
self.selection = vec![0];
self.open_rename(window, cx);
}
52 => {
let Some(Overlay::Rename { field }) = &self.dialog else {
panic!("the rename dialog opens for the CJK test");
};
field.update(cx, |field, cx| field.set_text("", cx));
return Probe::Keys(type_keys("页面1"));
}
53 => {
let Some(Overlay::Rename { field }) = &self.dialog else {
panic!("the rename dialog holds the CJK text");
};
log::info!("probe: cjk text={}", field.read(cx).text());
return Probe::KeyUpOnly(plain("enter"));
}
54 => {
let Some(Overlay::Rename { field }) = &self.dialog else {
panic!("an enter key-up alone never submits");
};
log::info!(
"probe: keyup-only enter keeps dialog=true titles={}",
self.page_titles().join("|")
);
log::info!("probe: keyup kept text={}", field.read(cx).text());
return Probe::Keys(vec![secondary("z")]);
}
55 => {
let Some(Overlay::Rename { field }) = &self.dialog else {
panic!("the rename dialog stays through undo");
};
log::info!("probe: cjk undo={}", field.read(cx).text());
self.selection = vec![1];
cx.notify();
return Probe::Keys(vec![plain("escape")]);
}
56 => {
if self.dialog.is_none() {
self.open_delete(cx);
return Probe::Again;
}
if !self.delete_ready(window, cx) {
return Probe::Again;
}
return Probe::Keys(vec![plain("tab"), plain("enter")]);
}
57 => {
log::info!(
"probe: delete cancel pages={}",
self.page_titles().join("|")
);
self.open_delete(cx);
}
58 => {
if !self.delete_ready(window, cx) {
return Probe::Again;
}
return Probe::Keys(vec![plain("tab"), plain("tab"), plain("enter")]);
}
59 => {
log::info!("probe: delete done pages={}", self.page_titles().join("|"));
self.open_delete(cx);
}
60 => {
if !self.delete_ready(window, cx) {
return Probe::Again;
}
return Probe::Keys(vec![plain("tab"), plain("tab"), plain("enter")]);
}
61 => {
log::info!("probe: delete last pages={}", self.page_titles().join("|"));
self.open_rename(window, cx);
log::info!("probe: empty rename refused={}", self.dialog.is_none());
self.open_delete(cx);
log::info!("probe: empty delete refused={}", self.dialog.is_none());
self.start_task(false, cx);
return Probe::Keys(vec![shift_p()]);
}
62 => {
let palette = root.read(cx).palette_open();
if !self.ready(palette && self.away(&props, window, cx)) {
return Probe::Again;
}
log::info!(
"probe: palette during task at {}",
self.task_state(cx).unwrap_or_else(|| "none".into())
);
return Probe::Keys(vec![plain("escape")]);
}
63 => {
if !self.ready(self.task_state(cx).as_deref() == Some("Succeeded")) {
return Probe::Again;
}
log::info!(
"probe: task export -> {}",
self.task_state(cx).unwrap_or_else(|| "none".into())
);
self.start_task(false, cx);
}
64 => {
if !self.ready(self.beats_began() >= 1) {
return Probe::Again;
}
let chip = self.chip.clone();
window.focus(&chip, cx);
return Probe::Keys(vec![plain("enter")]);
}
65 => {
if !self.ready(self.task_state(cx).as_deref() == Some("Cancelled")) {
return Probe::Again;
}
log::info!("probe: task cancel beats={}", self.beats_began());
self.start_task(true, cx);
}
66 => {
if !self.ready(self.beats_began() == 3) {
return Probe::Again;
}
if std::env::var("ELY_PROBE_HOLD_AT").as_deref() == Ok("running") {
log::info!("probe: hold at running");
return Probe::Again;
}
let chip = self.chip.clone();
window.focus(&chip, cx);
return Probe::Keys(vec![plain("enter")]);
}
67 => {
if !self.ready(matches!(self.task_state(cx).as_deref(), Some(state) if state.starts_with("Failed("))) {
return Probe::Again;
}
if std::env::var("ELY_PROBE_HOLD_AT").as_deref() == Ok("failed") {
log::info!("probe: hold at failed");
return Probe::Again;
}
log::info!(
"probe: task error kept -> {}",
self.task_state(cx).unwrap_or_else(|| "none".into())
);
window.focus(&nav, cx);
return Probe::Keys(vec![plain("tab")]);
}
68 => {
if self.chip.is_focused(window) {
log::info!("probe: chip by tab=true tries={}", self.frames);
self.frames = 0;
return Probe::Keys(vec![plain("enter")]);
}
if self.frames >= 12 {
log::info!("probe: chip by tab=false");
self.frames = 0;
return Probe::Keys(vec![plain("enter")]);
}
self.frames += 1;
return Probe::KeysAgain(vec![plain("tab")]);
}
69 => {
if !self.ready(self.task_state(cx).as_deref() == Some("Succeeded")) {
return Probe::Again;
}
log::info!(
"probe: task after retry -> {}",
self.task_state(cx).unwrap_or_else(|| "none".into())
);
}
70 => {
return Probe::Keys(vec![shift_p()]);
}
71 => {
if !self.palette_wait(palette, &props, window, cx) {
return Probe::Again;
}
return Probe::KeyUpOnly(plain("enter"));
}
72 => {
log::info!(
"probe: keyup-only enter keeps palette={} titles={}",
root.read(cx).palette_open(),
self.page_titles().join("|")
);
return Probe::Keys(vec![plain("escape")]);
}
73 => {
log::info!(
"probe: keyup palette closed={}",
!root.read(cx).palette_open()
);
}
_ => {
let root = root.read(cx);
log::info!("probe: palette open={}", root.palette_open());
log::info!("probe: recent={}", root.recent().join(","));
return Probe::Done;
}
}
Probe::Next
}
}
@@ -0,0 +1,182 @@
use ely_ui::Root;
use gpui::{App, Entity, KeyUpEvent, Keystroke, Modifiers, OwnedMenuItem, PlatformInput, Window};
use crate::Gallery;
pub enum Probe {
Again,
Next,
Keys(Vec<Keystroke>),
KeysAgain(Vec<Keystroke>),
Actions(Vec<Box<dyn gpui::Action>>),
Click { x: f32, y: f32 },
KeyUpOnly(Keystroke),
Done,
}
pub fn run(
root: Entity<Root>,
view: Entity<Gallery>,
step: usize,
window: &mut Window,
cx: &mut App,
) {
match view.update(cx, |this, cx| this.probe_step(&root, step, window, cx)) {
Probe::Again => window.on_next_frame(move |window, cx| run(root, view, step, window, cx)),
Probe::Next => {
window.on_next_frame(move |window, cx| run(root, view, step + 1, window, cx))
}
Probe::Keys(keys) => {
for key in keys {
window.dispatch_keystroke(key.clone(), cx);
window.dispatch_event(PlatformInput::KeyUp(KeyUpEvent { keystroke: key }), cx);
}
window.on_next_frame(move |window, cx| run(root, view, step + 1, window, cx))
}
Probe::KeysAgain(keys) => {
for key in keys {
window.dispatch_keystroke(key.clone(), cx);
window.dispatch_event(PlatformInput::KeyUp(KeyUpEvent { keystroke: key }), cx);
}
window.on_next_frame(move |window, cx| run(root, view, step, window, cx))
}
Probe::Click { x, y } => {
let position = gpui::point(gpui::px(x), gpui::px(y));
let modifiers = Modifiers::default();
window.dispatch_event(
PlatformInput::MouseDown(gpui::MouseDownEvent {
button: gpui::MouseButton::Left,
position,
modifiers,
click_count: 1,
first_mouse: false,
}),
cx,
);
window.dispatch_event(
PlatformInput::MouseUp(gpui::MouseUpEvent {
button: gpui::MouseButton::Left,
position,
modifiers,
click_count: 1,
}),
cx,
);
window.on_next_frame(move |window, cx| run(root, view, step + 1, window, cx))
}
Probe::Actions(actions) => {
for action in actions {
window.dispatch_action(action, cx);
}
window.on_next_frame(move |window, cx| run(root, view, step + 1, window, cx))
}
Probe::KeyUpOnly(key) => {
window.dispatch_event(PlatformInput::KeyUp(KeyUpEvent { keystroke: key }), cx);
window.on_next_frame(move |window, cx| run(root, view, step + 1, window, cx))
}
Probe::Done => {
println!("probe done");
cx.quit();
}
}
}
pub fn chord(i: usize) -> Keystroke {
let (key, shift) = [
("n", false),
("o", false),
("s", false),
("s", true),
("e", false),
("p", false),
][i];
Keystroke {
key: key.into(),
modifiers: Modifiers {
shift,
..Modifiers::secondary_key()
},
..Default::default()
}
}
pub fn shift_p() -> Keystroke {
Keystroke {
key: "p".into(),
modifiers: Modifiers {
shift: true,
..Modifiers::secondary_key()
},
..Default::default()
}
}
pub fn secondary(key: &str) -> Keystroke {
Keystroke {
key: key.into(),
modifiers: Modifiers::secondary_key(),
..Default::default()
}
}
pub fn plain(key: &str) -> Keystroke {
Keystroke {
key: key.into(),
..Default::default()
}
}
pub fn type_keys(word: &str) -> Vec<Keystroke> {
word.chars()
.map(|letter| Keystroke {
key: letter.into(),
key_char: Some(letter.into()),
..Default::default()
})
.collect()
}
pub fn file_menu(cx: &mut App) -> (String, Vec<String>, Vec<(String, String)>) {
let menus = cx.get_menus().unwrap_or_default();
let app = menus.first().map(|menu| menu.name.to_string());
let mut labels = Vec::new();
let mut map = Vec::new();
for menu in menus.iter().skip(1) {
for item in &menu.items {
if let OwnedMenuItem::Action { name, action, .. } = item {
labels.push(name.clone());
map.push((name.clone(), action.name().to_string()));
}
}
}
(app.unwrap_or_default(), labels, map)
}
pub fn menu_action(i: usize, cx: &mut App) -> Box<dyn gpui::Action> {
let actions: Vec<Box<dyn gpui::Action>> = cx
.get_menus()
.unwrap_or_default()
.into_iter()
.nth(1)
.map(|menu| {
menu.items
.iter()
.filter_map(|item| match item {
OwnedMenuItem::Action { action, .. } => Some(action.boxed_clone()),
_ => None,
})
.collect()
})
.unwrap_or_default();
let Some(action) = actions.into_iter().nth(i) else {
panic!("the File menu has a {i}th action");
};
action
}
pub fn ran_first(root: &Entity<Root>, cx: &mut App) -> String {
root.read(cx)
.last()
.map(|ran| ran.to_string())
.unwrap_or_else(|| "none".into())
}
+82
View File
@@ -0,0 +1,82 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::{PropertyGrid, PropertyGroup},
feedback::EmptyState,
primitives::IconName,
theme::ActiveTheme,
};
use gpui::{
Context, FocusHandle, IntoElement, ParentElement, Render, Styled, WeakEntity, Window, div,
};
use crate::{Gallery, anchor};
pub(crate) struct Props {
pub(crate) anchor: FocusHandle,
pub(crate) gallery: WeakEntity<Gallery>,
pub(crate) rows: Vec<(String, String)>,
pub(crate) empty: bool,
}
impl Render for Props {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
if self.empty {
return div()
.h_full()
.child(EmptyState::new(
"no-selection",
IconName::File,
ely_ui::i18n::text(cx, "gallery.empty.pages", &[]),
))
.into_any_element();
}
let gallery = self.gallery.clone();
let rename = Button::new(
"rename-button",
ely_ui::i18n::text(cx, "gallery.rename", &[]),
)
.variant(ButtonVariant::Outline)
.on_click(move |_, window, cx| {
if let Err(error) = gallery.update(cx, |this, cx| this.open_rename(window, cx)) {
log::error!("props: the gallery is gone: {error:#}");
}
});
let gallery = self.gallery.clone();
let delete = Button::new(
"delete-button",
ely_ui::i18n::text(cx, "gallery.delete", &[]),
)
.variant(ButtonVariant::Outline)
.on_click(move |_, _, cx| {
if let Err(error) = gallery.update(cx, |this, cx| this.open_delete(cx)) {
log::error!("props: the gallery is gone: {error:#}");
}
});
let group = self.rows.iter().fold(
PropertyGroup::new(ely_ui::i18n::text(cx, "gallery.group.selection", &[])),
|group, (name, value)| group.row(name.clone(), value.clone()),
);
div()
.h_full()
.p_2()
.flex()
.flex_col()
.gap_2()
.text_color(theme.colors.fg)
.child(anchor(
"prop-anchor",
ely_ui::i18n::text(cx, "gallery.props", &[]),
&self.anchor,
cx,
))
.child(
div()
.flex_1()
.min_h_0()
.child(PropertyGrid::new("selection-grid").group(group)),
)
.child(div().flex_none().flex().gap_2().child(rename).child(delete))
.into_any_element()
}
}