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,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()
|
||||
}
|
||||
@@ -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", &[]))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user