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,358 @@
|
||||
//! Sheet tabs and the workbook's sheet commands (TABLE-01).
|
||||
|
||||
use std::io;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::IconButton,
|
||||
forms::{Choice, TextInput},
|
||||
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
|
||||
navigation::{TabPlacement, Tabs as SheetTabs},
|
||||
primitives::{IconName, Severity},
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext as _, Context, Div, InteractiveElement as _, IntoElement,
|
||||
Menu, MenuItem, ParentElement as _, Styled as _, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::book::Book;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
actions!(
|
||||
table,
|
||||
[
|
||||
InsertSheet,
|
||||
RenameSheet,
|
||||
DuplicateSheet,
|
||||
MoveSheetLeft,
|
||||
MoveSheetRight,
|
||||
HideSheet,
|
||||
ShowSheet,
|
||||
DeleteSheet
|
||||
]
|
||||
);
|
||||
|
||||
pub fn menu(cx: &App) -> Menu {
|
||||
let label = |key: &str| Book::text(cx, key, &[]);
|
||||
Menu::new(label("menu.sheet")).items([
|
||||
MenuItem::action(label("table.sheet.insert"), InsertSheet),
|
||||
MenuItem::action(label("table.sheet.rename"), RenameSheet),
|
||||
MenuItem::action(label("table.sheet.duplicate"), DuplicateSheet),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("table.sheet.left"), MoveSheetLeft),
|
||||
MenuItem::action(label("table.sheet.right"), MoveSheetRight),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("table.sheet.hide"), HideSheet),
|
||||
MenuItem::action(label("table.sheet.show"), ShowSheet),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("table.protect.menu"), crate::protect::ProtectSheet),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("table.sheet.delete"), DeleteSheet),
|
||||
])
|
||||
}
|
||||
|
||||
/// The same commands on a right click.
|
||||
fn popup(cx: &App) -> Popup {
|
||||
let item = |key: &str, action: Box<dyn Action>| {
|
||||
PopupItem::new(Book::text(cx, key, &[]))
|
||||
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
|
||||
};
|
||||
Popup::new()
|
||||
.item(item("table.sheet.insert", Box::new(InsertSheet)))
|
||||
.item(item("table.sheet.rename", Box::new(RenameSheet)))
|
||||
.item(item("table.sheet.duplicate", Box::new(DuplicateSheet)))
|
||||
.separator()
|
||||
.item(item("table.sheet.left", Box::new(MoveSheetLeft)))
|
||||
.item(item("table.sheet.right", Box::new(MoveSheetRight)))
|
||||
.separator()
|
||||
.item(item("table.sheet.hide", Box::new(HideSheet)))
|
||||
.item(item("table.sheet.show", Box::new(ShowSheet)))
|
||||
.separator()
|
||||
.item(item("table.sheet.delete", Box::new(DeleteSheet)))
|
||||
}
|
||||
|
||||
pub(crate) mod dialogs;
|
||||
|
||||
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
|
||||
root.on_action(cx.listener(|book, _: &InsertSheet, _, cx| book.insert_sheet(cx)))
|
||||
.on_action(cx.listener(|book, _: &RenameSheet, window, cx| book.ask_rename(window, cx)))
|
||||
.on_action(cx.listener(|book, _: &DuplicateSheet, _, cx| book.duplicate_sheet(cx)))
|
||||
.on_action(cx.listener(|book, _: &MoveSheetLeft, _, cx| book.move_sheet(false, cx)))
|
||||
.on_action(cx.listener(|book, _: &MoveSheetRight, _, cx| book.move_sheet(true, cx)))
|
||||
.on_action(cx.listener(|book, _: &HideSheet, _, cx| book.hide_sheet(cx)))
|
||||
.on_action(cx.listener(|book, _: &ShowSheet, _, cx| book.ask_show(cx)))
|
||||
.on_action(
|
||||
cx.listener(|book, _: &DeleteSheet, _, cx| book.ask_delete(book.core.active, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
impl Book {
|
||||
/// The shown sheet, its name, and every name.
|
||||
fn current_sheet(&self) -> Option<(usize, String, Vec<String>)> {
|
||||
self.sheet_in(self.core.active?)
|
||||
}
|
||||
|
||||
/// Tab `id`: shown sheet, its name, every name.
|
||||
pub(crate) fn sheet_in(&self, id: u64) -> Option<(usize, String, Vec<String>)> {
|
||||
let sheets = &self.tab(id)?.extra.sheets;
|
||||
let names: Vec<String> = sheets.list.iter().map(|sheet| sheet.name.clone()).collect();
|
||||
Some((sheets.current, names.get(sheets.current)?.clone(), names))
|
||||
}
|
||||
|
||||
fn warn(&self, key: &str, cx: &mut Context<Self>) {
|
||||
let text = Book::text(cx, key, &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
|
||||
/// The visible sheets as tabs, with an add button.
|
||||
pub fn sheet_strip(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let Some(choices) = visible(&tab.extra.sheets) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let this = cx.entity();
|
||||
let picked = this.clone();
|
||||
let tabs = SheetTabs::new(
|
||||
"table-sheets",
|
||||
choices,
|
||||
tab.extra.sheets.current.to_string(),
|
||||
)
|
||||
.placement(TabPlacement::Bottom)
|
||||
.on_change(move |value, _, cx| {
|
||||
let index: usize = value.parse().expect("sheet tabs are indexes");
|
||||
picked.update(cx, |book, cx| book.show_sheet(index, cx));
|
||||
})
|
||||
.on_context(move |value, at, _, cx| {
|
||||
let index: usize = value.parse().expect("sheet tabs are indexes");
|
||||
this.update(cx, |book, cx| {
|
||||
book.show_sheet(index, cx);
|
||||
book.sheet_menu = Some((book.sheet_menu.map_or(0, |(n, _)| n + 1), at));
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
let add = IconButton::new("table-sheet-add", IconName::Plus)
|
||||
.tooltip(Book::text(cx, "table.sheet.add", &[]))
|
||||
.on_click(cx.listener(|book, _, _, cx| book.insert_sheet(cx)));
|
||||
ContextMenu::new("table-sheet-menu", popup(cx))
|
||||
.manual(self.sheet_menu)
|
||||
.child(div().flex().items_center().px_2().child(tabs).child(add))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Shows sheet `index` in order with the queued input.
|
||||
pub(crate) fn show_sheet(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let (id, view) = (tab.id, tab.view.clone());
|
||||
view.update(cx, |view, _| view.show_part(index));
|
||||
self.tab_mut(id)
|
||||
.expect("the tab is open")
|
||||
.extra
|
||||
.sheets
|
||||
.current = index;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn insert_sheet(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((current, _, names)) = self.current_sheet() else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
let name = (1..)
|
||||
.map(|n| Book::text(cx, "table.sheet.default", &[("n", &n.to_string())]))
|
||||
.find(|name| !names.iter().any(|taken| taken == name.as_ref()))
|
||||
.expect("a free name");
|
||||
let at = (current + 2).to_string();
|
||||
self.run_active("sheet.insert", &[&name, &at], cx);
|
||||
}
|
||||
|
||||
fn duplicate_sheet(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((current, ..)) = self.current_sheet() else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
self.run_active(
|
||||
"sheet.move",
|
||||
&[&(current + 2).to_string(), "true", "true"],
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Moves the shown sheet; Index is its final place.
|
||||
fn move_sheet(&mut self, right: bool, cx: &mut Context<Self>) {
|
||||
let Some((current, _, names)) = self.current_sheet() else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
if (right && current + 1 == names.len()) || (!right && current == 0) {
|
||||
return self.warn("table.sheet.edge", cx);
|
||||
}
|
||||
let at = if right { current + 2 } else { current };
|
||||
self.run_active("sheet.move", &[&at.to_string(), "false", "true"], cx);
|
||||
}
|
||||
|
||||
fn visible_count(&self, id: u64) -> usize {
|
||||
self.tab(id).map_or(0, |tab| {
|
||||
tab.extra
|
||||
.sheets
|
||||
.list
|
||||
.iter()
|
||||
.filter(|sheet| sheet.visible)
|
||||
.count()
|
||||
})
|
||||
}
|
||||
|
||||
fn hide_sheet(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((current, ..)) = self.current_sheet() else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
if self.core.active.is_none_or(|id| self.visible_count(id) < 2) {
|
||||
return self.warn("table.sheet.last", cx);
|
||||
}
|
||||
self.run_active("sheet.hide", &[¤t.to_string()], cx);
|
||||
}
|
||||
|
||||
fn ask_show(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
let hidden: Vec<String> = tab
|
||||
.extra
|
||||
.sheets
|
||||
.list
|
||||
.iter()
|
||||
.filter(|sheet| !sheet.visible)
|
||||
.map(|sheet| sheet.name.clone())
|
||||
.collect();
|
||||
let Some(chosen) = hidden.first().cloned() else {
|
||||
return self.warn("table.show.none", cx);
|
||||
};
|
||||
let tab = tab.id;
|
||||
self.dialog = Some(Dialog::Show {
|
||||
tab,
|
||||
hidden,
|
||||
chosen,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn ask_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let (Some(tab), Some((index, name, _))) = (self.core.active, self.current_sheet()) else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
// The list shown was read in this state.
|
||||
let Some(at) = self.read_at(tab, cx) else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
let field = cx.new(|cx| TextInput::new(window, cx));
|
||||
field.update(cx, |field, cx| field.set_text(name, cx));
|
||||
self.dialog = Some(Dialog::Rename { tab, at, index, field });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Asks before a delete, naming what refers (UX-07).
|
||||
pub(crate) fn ask_delete(&mut self, id: Option<u64>, cx: &mut Context<Self>) {
|
||||
let Some(id) = id else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |book, _, cx| book.ask_delete(Some(id), cx));
|
||||
}
|
||||
let Some((index, name, _)) = self.sheet_in(id) else {
|
||||
return self.warn("command.blocked.no_document", cx);
|
||||
};
|
||||
if self.visible_count(id) < 2 {
|
||||
return self.warn("table.sheet.last", cx);
|
||||
}
|
||||
self.ask_in(
|
||||
id,
|
||||
move |worker, doc| {
|
||||
// The sheet is checked and deleted in one state.
|
||||
let at = worker.now(doc)?;
|
||||
if doc.sheets(worker)?.list.get(index).map(|sheet| &sheet.name) != Some(&name) {
|
||||
return Err(io::Error::other(format!("{}sheet {index} moved", ely_lok::STALE)));
|
||||
}
|
||||
let json = worker.values(doc, &ely_lok::sheet::references_query(index))?;
|
||||
Ok((ely_lok::sheet::references(&json)?, at, name))
|
||||
},
|
||||
move |book, read, _, cx| match read {
|
||||
Ok((references, at, name)) => {
|
||||
book.dialog = Some(Dialog::Delete {
|
||||
tab: id,
|
||||
at,
|
||||
index,
|
||||
name,
|
||||
references,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) if ely_lok::is_stale(&error) => book.warn("office.error.changed", cx),
|
||||
Err(error) => book.engine_failed(&error.to_string(), cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Renames the sheet the open dialog asked about.
|
||||
pub(crate) fn rename_asked(&mut self, name: &str, cx: &mut Context<Self>) {
|
||||
let Some(Dialog::Rename { tab, at, index, .. }) = self.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
self.run_on(tab, &at, "sheet.rename", &[name, &(index + 1).to_string()], cx);
|
||||
}
|
||||
|
||||
/// Deletes the sheet the open dialog asked about.
|
||||
pub(crate) fn delete_asked(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Dialog::Delete { tab, at, index, .. }) = self.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
self.run_on(tab, &at, "sheet.delete", &[&(index + 1).to_string()], cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible sheets as tabs, once the shown one is known.
|
||||
fn visible(sheets: &ely_lok::Sheets) -> Option<Vec<Choice>> {
|
||||
let shown = sheets.list.get(sheets.current)?;
|
||||
if !shown.visible {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
sheets
|
||||
.list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, sheet)| sheet.visible)
|
||||
.map(|(index, sheet)| Choice::new(index.to_string(), sheet.name.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ely_lok::{Sheet, Sheets};
|
||||
|
||||
#[test]
|
||||
fn tabs_wait_for_a_known_shown_sheet() {
|
||||
assert!(visible(&Sheets::default()).is_none(), "no list yet");
|
||||
let sheet = |name: &str, visible| Sheet {
|
||||
name: name.into(),
|
||||
visible,
|
||||
};
|
||||
let hidden = Sheets {
|
||||
current: 1,
|
||||
list: vec![sheet("A", true), sheet("B", false)],
|
||||
};
|
||||
assert!(visible(&hidden).is_none(), "a hidden current waits");
|
||||
let shown = Sheets {
|
||||
current: 0,
|
||||
list: vec![sheet("A", true), sheet("B", false), sheet("C", true)],
|
||||
};
|
||||
let values: Vec<_> = visible(&shown)
|
||||
.expect("tabs")
|
||||
.into_iter()
|
||||
.map(|choice| choice.value)
|
||||
.collect();
|
||||
assert_eq!(values, ["0", "2"]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user