Grid and header menus share ely_office::menu with ElyDoc. Each item's availability is the kernel's state for the exact command it runs (patch 0045). Patch 0046 lets a header select its row or column while a shape is selected or its text is edited: the view shell ends the drawing edit, keeps the typed text, leaves drawing mode, then marks. Ely no longer sends Esc first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
256 lines
9.0 KiB
Rust
256 lines
9.0 KiB
Rust
//! The Cells menu: rows, columns, merges, sizes, freeze (TABLE-02).
|
|
|
|
use ely_gpui_component::{
|
|
buttons::{Button, ButtonVariant},
|
|
forms::TextInput,
|
|
overlays::{Dialog as Card, PromptDialog},
|
|
};
|
|
use ely_office::prelude::*;
|
|
use gpui::{
|
|
App, AppContext as _, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, Window,
|
|
actions,
|
|
};
|
|
|
|
use crate::book::Book;
|
|
use crate::dialog::Dialog;
|
|
|
|
actions!(
|
|
table,
|
|
[
|
|
RowsAbove,
|
|
RowsBelow,
|
|
ColumnsBefore,
|
|
ColumnsAfter,
|
|
InsertDown,
|
|
InsertRight,
|
|
DeleteRows,
|
|
DeleteColumns,
|
|
DeleteUp,
|
|
DeleteLeft,
|
|
Merge,
|
|
Split,
|
|
RowHeight,
|
|
ColumnWidth,
|
|
FitRows,
|
|
FitColumns,
|
|
HideRows,
|
|
ShowRows,
|
|
HideColumns,
|
|
ShowColumns,
|
|
Freeze
|
|
]
|
|
);
|
|
|
|
pub fn menu(cx: &App) -> Menu {
|
|
let label = |key: &str| Book::text(cx, key, &[]);
|
|
Menu::new(label("menu.cells")).items([
|
|
MenuItem::action(label("table.cells.rows_above"), RowsAbove),
|
|
MenuItem::action(label("table.cells.rows_below"), RowsBelow),
|
|
MenuItem::action(label("table.cells.columns_before"), ColumnsBefore),
|
|
MenuItem::action(label("table.cells.columns_after"), ColumnsAfter),
|
|
MenuItem::action(label("table.cells.insert_down"), InsertDown),
|
|
MenuItem::action(label("table.cells.insert_right"), InsertRight),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.cells.delete_rows"), DeleteRows),
|
|
MenuItem::action(label("table.cells.delete_columns"), DeleteColumns),
|
|
MenuItem::action(label("table.cells.delete_up"), DeleteUp),
|
|
MenuItem::action(label("table.cells.delete_left"), DeleteLeft),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.cells.merge"), Merge),
|
|
MenuItem::action(label("table.cells.split"), Split),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.cells.row_height"), RowHeight),
|
|
MenuItem::action(label("table.cells.column_width"), ColumnWidth),
|
|
MenuItem::action(label("table.cells.fit_rows"), FitRows),
|
|
MenuItem::action(label("table.cells.fit_columns"), FitColumns),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.cells.hide_rows"), HideRows),
|
|
MenuItem::action(label("table.cells.show_rows"), ShowRows),
|
|
MenuItem::action(label("table.cells.hide_columns"), HideColumns),
|
|
MenuItem::action(label("table.cells.show_columns"), ShowColumns),
|
|
MenuItem::separator(),
|
|
MenuItem::action(label("table.cells.freeze"), Freeze),
|
|
])
|
|
}
|
|
|
|
/// Binds an action to a command with fixed values.
|
|
macro_rules! run {
|
|
($root:expr, $cx:expr, $action:ty, $id:literal $(, $value:literal)*) => {
|
|
$root.on_action($cx.listener(|book, _: &$action, _, cx| {
|
|
book.run_active($id, &[$($value),*], cx)
|
|
}))
|
|
};
|
|
}
|
|
|
|
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
|
|
let root = run!(root, cx, RowsAbove, "rows.insert_above");
|
|
let root = run!(root, cx, RowsBelow, "rows.insert_below");
|
|
let root = run!(root, cx, ColumnsBefore, "columns.insert_before");
|
|
let root = run!(root, cx, ColumnsAfter, "columns.insert_after");
|
|
let root = run!(root, cx, InsertDown, "cells.insert", "V");
|
|
let root = run!(root, cx, InsertRight, "cells.insert", ">");
|
|
let root = run!(root, cx, DeleteRows, "rows.delete");
|
|
let root = run!(root, cx, DeleteColumns, "columns.delete");
|
|
let root = run!(root, cx, DeleteUp, "cells.delete", "U");
|
|
let root = run!(root, cx, DeleteLeft, "cells.delete", "L");
|
|
let root = run!(root, cx, Split, "cells.split");
|
|
let root = run!(root, cx, FitRows, "rows.optimal", "0");
|
|
let root = run!(root, cx, FitColumns, "columns.optimal", "0");
|
|
let root = run!(root, cx, HideRows, "rows.hide");
|
|
let root = run!(root, cx, ShowRows, "rows.show");
|
|
let root = run!(root, cx, HideColumns, "columns.hide");
|
|
let root = run!(root, cx, ShowColumns, "columns.show");
|
|
let root = run!(root, cx, Freeze, "view.freeze");
|
|
root.on_action(cx.listener(|book, _: &Merge, _, cx| book.merge(book.core.active, cx)))
|
|
.on_action(cx.listener(|book, _: &RowHeight, window, cx| book.ask_size(false, window, cx)))
|
|
.on_action(cx.listener(|book, _: &ColumnWidth, window, cx| book.ask_size(true, window, cx)))
|
|
}
|
|
|
|
impl Book {
|
|
/// Merges; asks first when more than one cell holds data.
|
|
pub(crate) fn merge(&mut self, id: Option<u64>, cx: &mut Context<Self>) {
|
|
let Some(tab) = id.and_then(|id| self.tab(id)) else {
|
|
return self.run_in(id, "cells.merge", &["false"], cx);
|
|
};
|
|
if !self.settled(tab.id, cx) {
|
|
return self.when_settled(tab.id, move |book, _, cx| book.merge(id, cx));
|
|
}
|
|
let tab = tab.id;
|
|
self.ask_in(
|
|
tab,
|
|
|worker, doc| doc.selection_text(worker),
|
|
move |book, text, _, cx| match text {
|
|
Ok(text) if filled(&text) > 1 => {
|
|
book.open_dialog(Dialog::Merge { tab });
|
|
cx.notify();
|
|
}
|
|
Ok(_) => book.run_in(id, "cells.merge", &["false"], cx),
|
|
Err(error) => book.engine_failed(&error.to_string(), cx),
|
|
},
|
|
cx,
|
|
);
|
|
}
|
|
|
|
/// Sets the asked size, in 1/100 mm, in its workbook.
|
|
pub(crate) fn size_asked(&mut self, size: i32, cx: &mut Context<Self>) {
|
|
let Some(Dialog::Size { tab, column, .. }) = self.dialog.take() else {
|
|
return;
|
|
};
|
|
let id = if column {
|
|
"columns.width_selected"
|
|
} else {
|
|
"rows.height_selected"
|
|
};
|
|
self.run_in(Some(tab), id, &[&size.to_string()], cx);
|
|
}
|
|
|
|
pub(crate) fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(tab) = self.core.active else {
|
|
let text = Book::text(cx, "command.blocked.no_document", &[]);
|
|
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
|
|
};
|
|
let field = cx.new(|cx| TextInput::new(window, cx));
|
|
self.open_dialog(Dialog::Size { tab, column, field });
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// How many cells of a tab-joined selection hold something.
|
|
fn filled(text: &str) -> usize {
|
|
text.lines()
|
|
.flat_map(|line| line.split('\t'))
|
|
.filter(|cell| !cell.is_empty())
|
|
.count()
|
|
}
|
|
|
|
/// Centimeters as 1/100 mm, within what the kernel takes.
|
|
fn hundredths(text: &str) -> Option<i32> {
|
|
let centimeters: f64 = text.trim().replace(',', ".").parse().ok()?;
|
|
let hundredths = (centimeters * 1000.0).round();
|
|
(1.0..=65535.0)
|
|
.contains(&hundredths)
|
|
.then_some(hundredths as i32)
|
|
}
|
|
|
|
pub fn size_dialog(
|
|
this: Entity<Book>,
|
|
(column, field): (bool, &Entity<TextInput>),
|
|
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
|
cx: &App,
|
|
) -> PromptDialog {
|
|
let title = Book::text(
|
|
cx,
|
|
if column {
|
|
"table.size.column"
|
|
} else {
|
|
"table.size.row"
|
|
},
|
|
&[],
|
|
);
|
|
let bad = Book::text(cx, "table.size.bad", &[]);
|
|
PromptDialog::new("table-size", title, field, dismiss)
|
|
.label(Book::text(cx, "table.size.label", &[]))
|
|
.submit(Book::text(cx, "table.size.apply", &[]))
|
|
.check(move |text| hundredths(text).map(|_| ()).ok_or_else(|| bad.clone()))
|
|
.on_submit(move |text, _, cx| {
|
|
let size = hundredths(text).expect("checked");
|
|
this.update(cx, |book, cx| book.size_asked(size, cx))
|
|
})
|
|
}
|
|
|
|
pub fn merge_dialog(
|
|
this: Entity<Book>,
|
|
tab: u64,
|
|
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
|
cx: &App,
|
|
) -> Card {
|
|
let choice = |id: &'static str, key: &str, moves: &'static str, variant| {
|
|
let (this, label) = (this.clone(), Book::text(cx, key, &[]));
|
|
move |_| {
|
|
Button::new(id, label)
|
|
.variant(variant)
|
|
.on_click(move |_, _, cx| {
|
|
this.update(cx, |book, cx| {
|
|
book.dialog = None;
|
|
book.run_in(Some(tab), "cells.merge", &[moves], cx);
|
|
})
|
|
})
|
|
}
|
|
};
|
|
Card::new(
|
|
"table-merge",
|
|
Book::text(cx, "table.merge.title", &[]),
|
|
dismiss,
|
|
)
|
|
.detail(Book::text(cx, "table.merge.detail", &[]))
|
|
.action(ely_office::cancel(cx))
|
|
.action(choice(
|
|
"table-merge-keep",
|
|
"table.merge.keep",
|
|
"false",
|
|
ButtonVariant::Secondary,
|
|
))
|
|
.action(choice(
|
|
"table-merge-move",
|
|
"table.merge.move",
|
|
"true",
|
|
ButtonVariant::Primary,
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn filled_cells_and_sizes() {
|
|
assert_eq!(filled("1\tb\n\t\n"), 2);
|
|
assert_eq!(filled("\t\n"), 0);
|
|
assert_eq!(hundredths("2.54"), Some(2540));
|
|
assert_eq!(hundredths("1,5"), Some(1500));
|
|
assert_eq!(hundredths("0"), None);
|
|
assert_eq!(hundredths("70"), None, "past 65.535 cm");
|
|
assert_eq!(hundredths("x"), None);
|
|
}
|
|
}
|