refactor(ui-egui): move ribbon.rs items to ribbon/

This commit is contained in:
aiongg
2026-10-26 21:16:41 +00:00
parent da0cb382ae
commit b36ec74f13
14 changed files with 2868 additions and 2794 deletions
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
use super::controls::control_keytip;
use super::*;
pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) {
if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() {
app.open_dialog("macros", json!({}));
}
let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false);
control_keytip(app, ui, &__k, "commandSearch");
if __k.clicked() {
app.open_dialog("commandSearch", json!({}));
}
let __k = big_button(ui, Icon::Script, "Agent\nControl", "How agents drive GridCraft (MCP / control channel)", false);
control_keytip(app, ui, &__k, "agents");
if __k.clicked() {
app.open_dialog("agents", json!({}));
}
let __k = big_button(ui, Icon::Script, "Action\nJournal", "Every command run in this session (replayable)", false);
control_keytip(app, ui, &__k, "journal");
if __k.clicked() {
app.open_dialog("journal", json!({}));
}
let __k = big_button(ui, Icon::Script, "Check for\nUpdates…", "Look for a newer GridCraft release", false);
control_keytip(app, ui, &__k, "checkForUpdates");
if __k.clicked() {
app.run_or_alert("app.checkForUpdates", json!({}));
}
let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false);
control_keytip(app, ui, &__k, "about");
if __k.clicked() {
app.open_dialog("about", json!({}));
}
}
+183
View File
@@ -0,0 +1,183 @@
use super::controls::{act, anchor_keytip, control_keytip, keytip, menu_items, sep};
use super::galleries::{paint_table_swatch, table_gallery};
use super::*;
/// Draws the Table Design tab: rename, resize, remove duplicates, convert to range, slicer, table options and the style strip.
pub(super) fn table_design(app: &mut SheetApp, ui: &mut Ui) {
let Some((tname, header, totals, banded, banded_c, first, last, filter, style)) = app.session.active().and_then(|d| {
let sh = d.wb.active()?;
let t = sh.table_at(d.selection.active)?;
Some((t.name.clone(), t.header_row, t.totals_row, t.banded_rows, t.banded_cols, t.first_col, t.last_col, t.filter_button, t.style.clone()))
}) else {
return;
};
ui.vertical(|ui| {
ui.label(egui::RichText::new("Table Name:").small());
let field = egui::Id::unique(("gridcraft.table_name", &tname));
if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0))
&& name != tname
{
act(app, "table.rename", json!({"table": tname, "name": name}));
}
});
let __k = big_button(ui, Icon::Table, "Resize\nTable", "Resize Table", false);
control_keytip(app, ui, &__k, "dialog:tableResize");
if __k.clicked() {
app.open_dialog("tableResize", json!({}));
}
sep(ui);
let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false);
control_keytip(app, ui, &__k, "data.removeDuplicates");
if __k.clicked() {
act(app, "data.removeDuplicates", json!({}));
}
let __k = big_button(ui, Icon::Table, "Convert\nto Range", "Convert to Range", false);
control_keytip(app, ui, &__k, "table.convertToRange");
if __k.clicked() {
act(app, "table.convertToRange", json!({"table": tname}));
}
sep(ui);
if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() {
act(app, "table.insertSlicer", json!({"table": tname}));
}
sep(ui);
ui.vertical(|ui| {
for (label, cmd, v) in [
("Header Row", "table.headerRow", header && filter),
("Total Row", "table.totalRow", totals),
("Banded Rows", "table.bandedRows", banded),
] {
let mut on = v;
if ui.checkbox(&mut on, label).changed() {
act(app, cmd, json!({"table": tname, "on": on}));
}
}
});
ui.vertical(|ui| {
for (label, cmd, v) in [
("First Column", "table.firstColumn", first),
("Last Column", "table.lastColumn", last),
("Banded Columns", "table.bandedColumns", banded_c),
] {
let mut on = v;
if ui.checkbox(&mut on, label).changed() {
act(app, cmd, json!({"table": tname, "on": on}));
}
}
});
sep(ui);
table_style_strip(app, ui, &tname, &style);
}
/// The first page of Excel's Table Styles gallery, drawn in the ribbon. The More arrow opens the
/// full gallery (Light, Medium, Dark) as a popup.
const RIBBON_TABLE_STYLES: [&str; 7] =
["TableStyleLight1", "TableStyleLight2", "TableStyleLight3", "TableStyleLight4", "TableStyleLight5", "TableStyleLight6", "TableStyleLight7"];
fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
// Borrow the workbook only while painting; the session must be free for `act` afterwards.
let clicked = {
let Some(d) = app.session.active() else { return };
let wb = &d.wb;
let mut clicked = None;
for name in RIBBON_TABLE_STYLES {
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 40.0), Sense::click());
paint_table_swatch(ui, r, wb, name);
if name == current {
ui.painter().rect_stroke(r.expand(2.5), 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside);
}
if resp.hovered() {
ui.painter().rect_stroke(r.expand(1.0), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
}
if resp.on_hover_text(name).clicked() {
clicked = Some(name);
}
}
clicked
};
if let Some(name) = clicked {
act(app, "table.style", json!({"table": tname, "style": name}));
}
let more = icon_button(ui, Icon::Chevron, t.text_dim, "More Table Styles", vec2(18.0, 40.0));
anchor_keytip(app, ui, &more, &["table.style"]);
egui::Popup::menu(&more).show(|ui| table_gallery(app, ui, "table.style"));
});
}
pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) {
let Some(id) = app.selected_chart else { return };
let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true);
for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) {
keytip(app, ui, sequence, ae.rect.center_bottom());
}
egui::Popup::menu(&ae).show(|ui| {
menu_items(
app,
ui,
&[
("Legend: Bottom", "chart.set", json!({"chart": id, "legend": "bottom"})),
("Legend: Right", "chart.set", json!({"chart": id, "legend": "right"})),
("Legend: Top", "chart.set", json!({"chart": id, "legend": "top"})),
("Legend: None", "chart.set", json!({"chart": id, "legend": "none"})),
("-", "", json!(null)),
("Data Labels: Show", "chart.set", json!({"chart": id, "dataLabels": true})),
("Data Labels: None", "chart.set", json!({"chart": id, "dataLabels": false})),
("Gridlines: Show", "chart.set", json!({"chart": id, "gridlines": true})),
("Gridlines: None", "chart.set", json!({"chart": id, "gridlines": false})),
],
);
});
let __k = big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false);
control_keytip(app, ui, &__k, "chart.switchRowColumn");
if __k.clicked() {
act(app, "chart.switchRowColumn", json!({"chart": id}));
}
if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() {
app.open_dialog("selectData", json!({"chart": id}));
}
let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&["column", "bar", "line", "area", "pie", "doughnut", "scatter", "radar", "waterfall", "funnel", "treemap"],
) {
keytip(app, ui, sequence, ct.rect.center_bottom());
}
egui::Popup::menu(&ct).show(|ui| {
for (label, k, s) in [
("Clustered Column", "column", ""),
("Stacked Column", "column", "stacked"),
("Bar", "bar", ""),
("Line", "line", "markers"),
("Area", "area", ""),
("Pie", "pie", ""),
("Doughnut", "doughnut", ""),
("Scatter", "scatter", ""),
("Radar", "radar", ""),
("Waterfall", "waterfall", ""),
("Funnel", "funnel", ""),
("Treemap", "treemap", ""),
] {
if ui.button(label).clicked() {
act(app, "chart.set", json!({"chart": id, "type": k, "subtype": s}));
}
}
});
if big_button(ui, Icon::Settings, "Format\nPane", "Format Chart pane", false).clicked() {
app.grid.pane = Some("formatChart".into());
}
let __k = big_button(ui, Icon::TextBox, "Chart\nTitle", "Edit chart title", false);
control_keytip(app, ui, &__k, "chartTitle");
if __k.clicked() {
app.open_dialog("chartTitle", json!({"chart": id}));
}
let __k = big_button(ui, Icon::Delete, "Delete\nChart", "Delete chart", false);
control_keytip(app, ui, &__k, "chart.delete");
if __k.clicked() {
act(app, "chart.delete", json!({"chart": id}));
app.selected_chart = None;
}
}
+270
View File
@@ -0,0 +1,270 @@
use super::*;
pub(super) fn sep(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), Sense::hover());
ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator));
}
pub(super) fn active_style(app: &SheetApp) -> Style {
app.session
.active()
.and_then(|d| {
let sh = d.wb.active()?;
Some(d.wb.styles.get(sh.style_id(d.selection.active)).clone())
})
.unwrap_or_default()
}
pub(super) fn theme_colors(app: &SheetApp) -> [u32; 12] {
app.session.active().map(|d| d.wb.theme.colors).unwrap_or(gridcraft_engine::model::Theme::default().colors)
}
/// Runs a command from a ribbon click (menu-style: no params may open a dialog).
pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
app.run_or_alert(id, params);
}
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text);
let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0));
painter.rect_filled(rect, 2.0, t.input_bg);
painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
painter.galley(rect.min + vec2(3.5, 1.5), text, t.text);
}
/// Paint the badge for a control that runs command `id`, when the armed prefix matches.
pub(super) fn control_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, id: &str) {
if let Some(sequence) = keytip_table::control_badge(app.keytips.prefix().unwrap_or_default(), id) {
keytip(app, ui, sequence, response.rect.center_bottom());
}
}
/// Paint the single badge for a menu anchor whose popup items run `targets`.
pub(super) fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[&str]) {
if let Some(sequence) = keytip_table::anchor_badge(app.keytips.prefix().unwrap_or_default(), targets) {
keytip(app, ui, sequence, response.rect.center_bottom());
}
}
/// The `on` value for a ribbon checkbox command. A click has already flipped the checkbox bool, so
/// it is sent as is; a key tip leaves the bool untouched, so the command gets the opposite.
pub(super) fn checkbox_on(clicked: bool, shown: bool) -> bool {
shown == clicked
}
/// Consume a one-shot keytip request for control `id` (set by `Action::Control`).
pub(super) fn invoke(app: &mut SheetApp, id: &str) -> bool {
if app.ui.keytip_invoke == Some(id) {
app.ui.keytip_invoke = None;
true
} else {
false
}
}
/// Force-open command for a popup: either a keytip invoke (open it) or a click (toggle).
pub(super) fn popup_command(forced: bool, clicked: bool) -> Option<egui::SetOpenCommand> {
if forced { Some(egui::SetOpenCommand::Bool(true)) } else { clicked.then_some(egui::SetOpenCommand::Toggle) }
}
/// Per-control state of an [`editable_combo`]: the filter text and highlighted row.
#[derive(Clone, Default)]
struct ComboState {
text: String,
sel: i32,
was_open: bool,
}
/// What the user did with an [`editable_combo`].
pub(crate) enum ComboResult {
None,
/// A row was applied: clicked or Enter on the highlighted row.
Picked(String),
}
/// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection.
/// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke)
/// or clicked, the popup opens, focuses the filter field, and selects its text.
pub(crate) fn editable_combo(
ui: &mut Ui,
anchor: &egui::Response,
salt: &'static str,
current_value: &str,
items: &[(&str, String)],
forced: bool,
) -> ComboResult {
combo_popup(ui, anchor, salt, current_value, items, forced, true, None)
}
/// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or
/// arrows and Enter. It drops directly below `under` and is as wide as it, like Excel's Font Size
/// list under its box.
pub(super) fn list_combo(
ui: &mut Ui,
anchor: &egui::Response,
under: Rect,
salt: &'static str,
current_value: &str,
items: &[(&str, String)],
) -> ComboResult {
combo_popup(ui, anchor, salt, current_value, items, false, false, Some(under))
}
fn combo_popup(
ui: &mut Ui,
anchor: &egui::Response,
salt: &'static str,
current_value: &str,
items: &[(&str, String)],
forced: bool,
filter: bool,
under: Option<Rect>,
) -> ComboResult {
let popup_id = anchor.id.with("popup");
let state_id = anchor.id.with(salt).with("__combo_state");
let mut st = ui.data_mut(|d| d.get_temp::<ComboState>(state_id)).unwrap_or_default();
let open_now = egui::Popup::is_id_open(ui.ctx(), popup_id);
if open_now && !st.was_open {
st.text = String::new();
st.sel = items.iter().position(|(v, _)| *v == current_value).map(|i| i as i32).unwrap_or(0);
// The reset lands on the frame AFTER the popup opens, which usually has no input of
// its own — without a repaint request the stale filtered list stays on screen.
ui.ctx().request_repaint();
}
st.was_open = open_now;
let mut result = ComboResult::None;
let mut popup = egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked()));
if let Some(r) = under {
popup = popup.anchor(r).align(egui::RectAlign::BOTTOM_START).width(r.width());
}
let row_w = under.map_or(220.0, |r| r.width() - 12.0);
popup.show(|ui| {
// Arrows and Enter navigate the list, not the filter field's cursor. Capture them and
// strip them from the input so the TextEdit never sees them.
let (mut down, mut up, mut enter) = (false, false, false);
ui.input(|i| {
down = i.key_pressed(egui::Key::ArrowDown);
up = i.key_pressed(egui::Key::ArrowUp);
enter = i.key_pressed(egui::Key::Enter);
});
if down || up || enter {
ui.ctx().input_mut(|i| {
i.events.retain(|e| {
!matches!(e, egui::Event::Key { key: egui::Key::ArrowDown | egui::Key::ArrowUp | egui::Key::Enter, pressed: true, .. })
});
});
}
if filter {
let text_id = anchor.id.with(salt).with("__combo_text");
let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…"));
if open_now && !te.has_focus() {
ui.ctx().memory_mut(|m| m.request_focus(text_id));
// Select all once focus lands so typing replaces the filter.
if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) {
let len = st.text.chars().count();
s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len))));
s.store(ui.ctx(), text_id);
}
}
}
let lower = st.text.to_lowercase();
let filtered: Vec<usize> = items.iter().enumerate().filter(|(_, (_, d))| d.to_lowercase().contains(&lower)).map(|(i, _)| i).collect();
if !filtered.is_empty() {
let n = filtered.len() as i32;
st.sel = st.sel.clamp(0, n - 1);
if down {
st.sel = (st.sel + 1).rem_euclid(n);
}
if up {
st.sel = (st.sel - 1).rem_euclid(n);
}
}
if enter && !filtered.is_empty() {
let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize];
result = ComboResult::Picked(items[idx].0.to_string());
}
if filter {
ui.separator();
}
let button_h = ui.spacing().interact_size.y.max(20.0);
let spacing = ui.spacing().item_spacing.y;
let n = filtered.len() as f32;
let content_h = n * button_h + (n - 1.0).max(0.0) * spacing;
let max_rows = 9.0_f32;
let max_h = max_rows * button_h + (max_rows - 1.0) * spacing;
let desired_h = content_h.min(max_h);
ui.set_min_height(desired_h);
egui::ScrollArea::vertical().max_height(desired_h).show(ui, |ui| {
for (pos, i) in filtered.iter().enumerate() {
let (value, disp) = &items[*i];
let row = ui.add(egui::Button::selectable(pos as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(row_w, 20.0)));
if (down || up) && pos as i32 == st.sel && !ui.clip_rect().contains_rect(row.rect) {
row.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min }));
}
if row.clicked() {
result = ComboResult::Picked(value.to_string());
}
if row.hovered() {
st.sel = pos as i32;
}
}
});
if !matches!(result, ComboResult::None) {
ui.close();
}
});
ui.data_mut(|d| d.insert_temp(state_id, st));
result
}
pub(super) fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) {
for (label, id, p) in items {
if *label == "-" {
ui.separator();
continue;
}
let lang = app.ui.language;
let response = ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(220.0, 22.0)));
if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) {
keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y));
}
if response.clicked() {
if let Some(d) = id.strip_prefix("dialog:") {
app.open_dialog(d, p.clone());
} else if *id == "edit.paste" {
app.paste_from_clipboard();
} else {
act(app, id, p.clone());
}
}
}
}
#[cfg(test)]
mod checkbox_on_tests {
use super::*;
#[test]
fn click_sends_the_new_checkbox_value_and_key_tip_the_opposite() {
// Click: the checkbox bool already holds the new choice.
assert!(!checkbox_on(true, false));
assert!(checkbox_on(true, true));
// Key tip: the bool still holds the current setting, so toggle it.
assert!(checkbox_on(false, false));
assert!(!checkbox_on(false, true));
}
}
+139
View File
@@ -0,0 +1,139 @@
use super::controls::{act, control_keytip, keytip, menu_items, sep};
use super::*;
pub(super) fn data(app: &mut SheetApp, ui: &mut Ui) {
let gd = big_button(ui, Icon::Folder, "Get Data\n(Text/CSV)", "Import a text or CSV file", false);
control_keytip(app, ui, &gd, "data.getData");
if gd.clicked() {
app.open_dialog("open", json!({}));
}
sep(ui);
ui.vertical(|ui| {
let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false);
control_keytip(app, ui, &__k, "data.sortAscending");
if __k.clicked() {
act(app, "data.sortAscending", json!({}));
}
let __k = small_button(ui, Icon::SortDesc, "", "Sort Z to A", false);
control_keytip(app, ui, &__k, "data.sortDescending");
if __k.clicked() {
act(app, "data.sortDescending", json!({}));
}
});
let __k = big_button(ui, Icon::SortFilter, "Sort", "Custom Sort", false);
control_keytip(app, ui, &__k, "dialog:sort");
if __k.clicked() {
app.open_dialog("sort", json!({}));
}
let __k = big_button(ui, Icon::Filter, "Filter", "Filter", false);
control_keytip(app, ui, &__k, "data.filter");
if __k.clicked() {
act(app, "data.filter", json!({}));
}
ui.vertical(|ui| {
let __k = small_button(ui, Icon::Eraser, "Clear", "Clear Filter", false);
control_keytip(app, ui, &__k, "data.clearFilter");
if __k.clicked() {
act(app, "data.clearFilter", json!({}));
}
let __k = small_button(ui, Icon::Redo, "Reapply", "Reapply", false);
control_keytip(app, ui, &__k, "data.reapply");
if __k.clicked() {
act(app, "data.reapply", json!({}));
}
let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false);
control_keytip(app, ui, &__k, "dialog:advancedFilter");
if __k.clicked() {
app.open_dialog("advancedFilter", json!({}));
}
});
sep(ui);
let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false);
control_keytip(app, ui, &__k, "dialog:textToColumns");
if __k.clicked() {
app.open_dialog("textToColumns", json!({}));
}
let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill", false);
control_keytip(app, ui, &__k, "edit.flashFill");
if __k.clicked() {
act(app, "edit.flashFill", json!({}));
}
let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false);
control_keytip(app, ui, &__k, "dialog:removeDuplicates");
if __k.clicked() {
app.open_dialog("removeDuplicates", json!({}));
}
let dv = big_button(ui, Icon::Validation, "Data\nValidation", "Data Validation", true);
for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["data.validation", "data.circleInvalid"]) {
keytip(app, ui, sequence, dv.rect.center_bottom());
}
egui::Popup::menu(&dv).show(|ui| {
menu_items(
app,
ui,
&[
("Data Validation…", "dialog:dataValidation", json!({})),
("Circle Invalid Data", "data.circleInvalid", json!({})),
("Clear Validation", "data.validation", json!({"clear": true})),
],
);
});
sep(ui);
let wi = big_button(ui, Icon::Calc, "What-If\nAnalysis", "What-If Analysis", true);
for sequence in
keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["dialog:goalSeek", "data.scenarioManager", "data.dataTable"])
{
keytip(app, ui, sequence, wi.rect.center_bottom());
}
egui::Popup::menu(&wi).show(|ui| {
menu_items(
app,
ui,
&[
("Goal Seek…", "dialog:goalSeek", json!({})),
("Scenario Manager…", "data.scenarioManager", json!({})),
("Data Table…", "data.dataTable", json!({})),
],
);
});
if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() {
app.open_dialog("forecastSheet", json!({}));
}
sep(ui);
ui.vertical(|ui| {
let __k = small_button(ui, Icon::Group, "Group", "Group", false);
control_keytip(app, ui, &__k, "data.group");
if __k.clicked() {
act(app, "data.group", json!({}));
}
let __k = small_button(ui, Icon::Ungroup, "Ungroup", "Ungroup", false);
control_keytip(app, ui, &__k, "data.ungroup");
if __k.clicked() {
act(app, "data.ungroup", json!({}));
}
let __k = small_button(ui, Icon::Subtotal, "Subtotal", "Subtotal", false);
control_keytip(app, ui, &__k, "dialog:subtotal");
if __k.clicked() {
app.open_dialog("subtotal", json!({}));
}
});
ui.vertical(|ui| {
if small_button(ui, Icon::Plus, "Show Detail", "Show Detail", false).clicked() {
act(app, "data.showDetail", json!({}));
}
if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() {
act(app, "data.hideDetail", json!({}));
}
if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() {
app.open_dialog("outlineSettings", json!({}));
}
});
sep(ui);
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
app.open_dialog("analysisToolPak", json!({}));
}
sep(ui);
if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() {
app.open_dialog("solver", json!({}));
}
}
+202
View File
@@ -0,0 +1,202 @@
use super::controls::{act, anchor_keytip, control_keytip, invoke, keytip, menu_items, popup_command, sep};
use super::home::autosum_menu;
use super::*;
/// How many cells Formulas › Error Checking › Circular References lists.
const CIRCULAR_MENU_LIMIT: usize = 100;
/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order
/// the calculation found them, as (sheet index, cell, "Sheet!A1").
fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> {
let Some(d) = app.session.active() else { return Vec::new() };
let mut seen = std::collections::HashSet::new();
d.calc
.circular
.iter()
.filter(|k| seen.insert(**k))
.filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", gridcraft_engine::formula::quote_sheet(&sh.name), c.a1()))))
.take(CIRCULAR_MENU_LIMIT)
.collect()
}
/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula
/// language, descriptions in the interface language), defined names, auditing and calculation.
pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) {
let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false);
control_keytip(app, ui, &__k, "dialog:insertFunction");
if __k.clicked() {
app.open_dialog("insertFunction", json!({}));
}
let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
control_keytip(app, ui, &sa, "formulas.autoSum");
if s.clicked() {
act(app, "formulas.autoSum", json!({}));
}
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
for (label, cat, cid) in [
("Financial", "Financial", "formulas.financial"),
("Logical", "Logical", "formulas.logical"),
("Text", "Text", "formulas.text"),
("Date &\nTime", "DateTime", "formulas.dateTime"),
("Lookup &\nReference", "Lookup", "formulas.lookup"),
("Math &\nTrig", "MathTrig", "formulas.mathTrig"),
("More\nFunctions", "Statistical", "formulas.moreFunctions"),
] {
let r = big_button(ui, Icon::Book, label, label, true);
control_keytip(app, ui, &r, cid);
let forced_c = invoke(app, cid);
egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| {
ui.set_max_height(420.0);
egui::ScrollArea::vertical().show(ui, |ui| {
for f in gridcraft_engine::cmd::formulas::function_list() {
if f["category"].as_str() == Some(cat)
&& let Some(n) = f["name"].as_str()
{
let locale = app.ui.language.formula_locale();
let name =
app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n);
let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default();
if ui.button(name).on_hover_text(desc).clicked() {
app.run_or_alert("formulas.insertFunction", json!({"name": n}));
ui.close();
}
}
}
});
});
}
sep(ui);
let __k = big_button(ui, Icon::Name, "Name\nManager", "Name Manager", false);
control_keytip(app, ui, &__k, "dialog:nameManager");
if __k.clicked() {
app.open_dialog("nameManager", json!({}));
}
ui.vertical(|ui| {
let __k = small_button(ui, Icon::Name, "Define Name", "Define Name", false);
control_keytip(app, ui, &__k, "dialog:defineName");
if __k.clicked() {
app.open_dialog("defineName", json!({}));
}
let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true);
anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]);
egui::Popup::menu(&use_in).show(|ui| {
// The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret.
// Listed when the menu opens, and again only after the workbook changes.
let cache = egui::Id::unique("gridcraft.use_in_formula_names");
let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet()));
let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc<Vec<String>>)>(cache)).filter(|(k, _)| *k == key);
let names = match cached {
Some((_, names)) => names,
None => {
let names: std::sync::Arc<Vec<String>> = std::sync::Arc::new(
app.session
.active()
.map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect())
.unwrap_or_default(),
);
ui.ctx().data_mut(|m| m.insert_temp(cache, (key, names.clone())));
names
}
};
for n in names.iter() {
if ui.button(n).clicked() {
app.run_or_alert("formulas.useInFormula", json!({"name": n}));
}
}
if !names.is_empty() {
ui.separator();
}
if ui.button(app.ui.language.tr("Paste Names…")).clicked() {
app.open_dialog("pasteName", json!({}));
}
});
let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false);
control_keytip(app, ui, &__k, "formulas.createFromSelection");
if __k.clicked() {
act(app, "formulas.createFromSelection", json!({}));
}
});
sep(ui);
ui.vertical(|ui| {
if small_button(ui, Icon::Trace, "Trace Precedents", "Trace Precedents", false).clicked()
&& let Ok(r) = app.run("formulas.tracePrecedents", json!({}))
{
app.grid.trace = crate::grid::Trace::new(app, r);
}
if small_button(ui, Icon::Trace, "Trace Dependents", "Trace Dependents", false).clicked()
&& let Ok(r) = app.run("formulas.traceDependents", json!({}))
{
app.grid.trace = crate::grid::Trace::new(app, r);
}
if small_button(ui, Icon::Close, "Remove Arrows", "Remove Arrows", false).clicked() {
app.grid.trace = None;
}
});
ui.vertical(|ui| {
let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas", false);
control_keytip(app, ui, &__k, "formulas.showFormulas");
if __k.clicked() {
act(app, "formulas.showFormulas", json!({}));
}
let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking");
control_keytip(app, ui, &ec, "dialog:errorChecking");
if ec.clicked() {
app.open_dialog("errorChecking", json!({}));
}
egui::Popup::menu(&ec_arrow).show(|ui| {
let lang = app.ui.language;
if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() {
app.open_dialog("errorChecking", json!({}));
}
let circular = circular_references(app);
ui.add_enabled_ui(!circular.is_empty(), |ui| {
ui.menu_button(lang.tr("Circular References"), |ui| {
egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| {
for (sheet, cell, label) in circular {
if ui.button(label).clicked() {
act(app, "selection.set", json!({"range": cell, "sheet": sheet}));
app.grid.ensure_visible = true;
}
}
});
});
});
});
let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false);
control_keytip(app, ui, &__k, "dialog:evaluateFormula");
if __k.clicked() {
app.open_dialog("evaluateFormula", json!({}));
}
});
if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() {
app.grid.pane = Some("watch".into());
}
sep(ui);
let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true);
for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["formulas.calculationOptions"]) {
keytip(app, ui, sequence, co.rect.center_bottom());
}
egui::Popup::menu(&co).show(|ui| {
menu_items(
app,
ui,
&[
("Automatic", "formulas.calculationOptions", json!({"mode": "automatic"})),
("Automatic Except for Data Tables", "formulas.calculationOptions", json!({"mode": "automaticExceptTables"})),
("Manual", "formulas.calculationOptions", json!({"mode": "manual"})),
],
);
});
ui.vertical(|ui| {
let __k = small_button(ui, Icon::Calc, "Calculate Now", "Calculate Now (F9)", false);
control_keytip(app, ui, &__k, "formulas.calculateNow");
if __k.clicked() {
act(app, "formulas.calculateNow", json!({}));
}
let __k = small_button(ui, Icon::Sheet, "Calculate Sheet", "Calculate Sheet (⇧F9)", false);
control_keytip(app, ui, &__k, "formulas.calculateSheet");
if __k.clicked() {
act(app, "formulas.calculateSheet", json!({}));
}
});
}
+145
View File
@@ -0,0 +1,145 @@
use super::controls::act;
use super::*;
/// A gallery of table styles drawn as mini tables.
pub(super) fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) {
ui.set_width(430.0);
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
for (fam, n) in [("Light", 21u32), ("Medium", 28), ("Dark", 11)] {
ui.label(egui::RichText::new(fam).strong());
egui::Grid::new(("tg", fam)).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
for i in 1..=n {
let name = format!("TableStyle{fam}{i}");
let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click());
paint_table_swatch(ui, r, &wb, &name);
if resp.hovered() {
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
}
if resp.on_hover_text(&name).clicked() {
act(app, cmd, json!({"style": name}));
ui.close();
}
if i % 7 == 0 {
ui.end_row();
}
}
});
}
}
pub(super) fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook, style: &str) {
let lk = gridcraft_engine::tables::look(style);
let res = |c: Option<gridcraft_engine::model::Color>| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32);
let p = ui.painter();
let t = Tokens::get(ui.ctx());
p.rect_filled(r, 0.0, t.grid_bg);
let rows = 5;
let rh = r.height() / rows as f32;
for i in 0..rows {
let rr = Rect::from_min_size(pos2(r.left(), r.top() + i as f32 * rh), vec2(r.width(), rh));
let fill = if i == 0 {
res(lk.header_fill)
} else if i % 2 == 1 {
res(lk.band_fill)
} else {
res(lk.body_fill)
};
if let Some(f) = fill {
p.rect_filled(rr, 0.0, f);
}
if let Some(l) = res(lk.line) {
p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(1.0, l));
}
for k in 0..3 {
let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0;
let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(t.cell_text) } else { t.sheet_control_border };
p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col));
}
}
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
}
pub(super) fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.set_width(520.0);
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
let groups: &[(&str, &[&str])] = &[
("Good, Bad and Neutral", &["Normal", "Bad", "Good", "Neutral"]),
("Data and Model", &["Calculation", "Check Cell", "Explanatory Text", "Input", "Linked Cell", "Note", "Output", "Warning Text"]),
("Titles and Headings", &["Heading 1", "Heading 2", "Heading 3", "Heading 4", "Title", "Total"]),
(
"Themed Cell Styles",
&[
"20% - Accent1",
"20% - Accent2",
"20% - Accent3",
"20% - Accent4",
"20% - Accent5",
"20% - Accent6",
"40% - Accent1",
"40% - Accent2",
"40% - Accent3",
"40% - Accent4",
"40% - Accent5",
"40% - Accent6",
"60% - Accent1",
"60% - Accent2",
"60% - Accent3",
"60% - Accent4",
"60% - Accent5",
"60% - Accent6",
"Accent1",
"Accent2",
"Accent3",
"Accent4",
"Accent5",
"Accent6",
],
),
("Number Format", &["Comma", "Comma [0]", "Currency", "Currency [0]", "Percent"]),
];
for (g, names) in groups {
ui.label(egui::RichText::new(*g).strong());
egui::Grid::new(("csg", *g)).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
for (i, n) in names.iter().enumerate() {
let st = gridcraft_engine::cmd::format::builtin_cell_style(n, &wb.theme).unwrap_or_default();
let (r, resp) = ui.allocate_exact_size(vec2(82.0, 24.0), Sense::click());
let fill = st
.fill
.fg
.resolve(&wb.theme)
.filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None)
.map(theme::color32)
.unwrap_or(t.grid_bg);
ui.painter().rect_filled(r, 0.0, fill);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
if !st.borders.bottom.is_none() {
let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
ui.painter().line_segment(
[r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)],
Stroke::new(st.borders.bottom.style.width(), c),
);
}
let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
Align2::LEFT_CENTER,
*n,
egui::FontId::new((st.font.size).clamp(10.0, 15.0), fam),
col,
);
if resp.hovered() {
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
}
if resp.clicked() {
act(app, "home.cellStyle", json!({"name": n}));
ui.close();
}
if (i + 1) % 6 == 0 {
ui.end_row();
}
}
});
}
}
+813
View File
@@ -0,0 +1,813 @@
use super::controls::{
ComboResult, act, active_style, anchor_keytip, control_keytip, editable_combo, invoke, keytip, list_combo, menu_items, popup_command, sep,
theme_colors,
};
use super::galleries::{cell_style_gallery, table_gallery};
use super::*;
/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol.
const ACCOUNTING_SYMBOLS: &[(&str, &str)] = &[
("$ English (United States)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"),
("£ English (United Kingdom)", "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"),
("€ Euro", "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"),
("¥ Japanese", "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"),
("¥ Chinese (PRC)", "_-[$¥-804]* #,##0.00_-;-[$¥-804]* #,##0.00_-;_-[$¥-804]* \"-\"??_-;_-@_-"),
("₹ English (India)", "_-[$₹-4009]* #,##0.00_-;-[$₹-4009]* #,##0.00_-;_-[$₹-4009]* \"-\"??_-;_-@_-"),
("CHF German (Switzerland)", "_-[$CHF-807]* #,##0.00_-;-[$CHF-807]* #,##0.00_-;_-[$CHF-807]* \"-\"??_-;_-@_-"),
("Rp Indonesian", "_-[$Rp-421]* #,##0.00_-;-[$Rp-421]* #,##0.00_-;_-[$Rp-421]* \"-\"??_-;_-@_-"),
("₩ Korean", "_-[$₩-412]* #,##0_-;-[$₩-412]* #,##0_-;_-[$₩-412]* \"-\"_-;_-@_-"),
("R$ Portuguese (Brazil)", "_-[$R$-416]* #,##0.00_-;-[$R$-416]* #,##0.00_-;_-[$R$-416]* \"-\"??_-;_-@_-"),
("₽ Russian", "_-[$₽-419]* #,##0.00_-;-[$₽-419]* #,##0.00_-;_-[$₽-419]* \"-\"??_-;_-@_-"),
];
/// Draws the Home tab: clipboard/paste menu, font, alignment, number, style, cell and editing commands with keytips.
pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let st = active_style(app);
// Clipboard
let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true);
paste.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Paste"));
for sequence in
keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"])
{
keytip(app, ui, sequence, paste.rect.center_bottom());
}
if paste.clicked() {
app.paste_from_clipboard();
}
let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E"));
// Keep a keyboard-opened menu anchored to the button when a pointer click
// cancels keytips, so its items remain in place and can still be clicked.
let anchor_id = paste.id.with("keytip_anchor");
// Query response state before taking the egui context write lock.
// Calling secondary_clicked() inside data_mut can deadlock.
let paste_secondary_clicked = paste.secondary_clicked();
let anchored = ui.ctx().data_mut(|data| {
let anchored = if paste_keys {
true
} else if paste_secondary_clicked {
false
} else {
data.get_temp::<bool>(anchor_id).unwrap_or(false)
};
data.insert_temp(anchor_id, anchored);
anchored
});
let mut paste_popup = egui::Popup::context_menu(&paste);
if anchored {
paste_popup = paste_popup.anchor(&paste);
}
if paste_keys {
paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true));
}
paste_popup.show(|ui| {
menu_items(
app,
ui,
&[
("Paste", "edit.paste", json!({})),
("Paste Values", "edit.pasteSpecial", json!({"what": "values"})),
("Paste Formulas", "edit.pasteSpecial", json!({"what": "formulas"})),
("Paste Formatting", "edit.pasteSpecial", json!({"what": "formats"})),
("Transpose", "edit.pasteSpecial", json!({"what": "all", "transpose": true})),
("Paste Link", "edit.pasteSpecial", json!({"what": "all", "link": true})),
("-", "", json!(null)),
("Paste Special…", "dialog:pasteSpecial", json!({})),
],
);
});
ui.vertical(|ui| {
let __k = icon_button(ui, Icon::Cut, t.text, "Cut", vec2(26.0, 23.0));
control_keytip(app, ui, &__k, "edit.cut");
if __k.clicked() {
app.copy_to_clipboard(ui.ctx(), "edit.cut");
}
let __k = icon_button(ui, Icon::Copy, t.text, "Copy", vec2(26.0, 23.0));
control_keytip(app, ui, &__k, "edit.copy");
if __k.clicked() {
app.copy_to_clipboard(ui.ctx(), "edit.copy");
}
let fp_on = app.session.format_painter.is_some();
let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)");
fp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), fp_on, "Format Painter"));
control_keytip(app, ui, &fp, "edit.formatPainter");
if fp.double_clicked() {
act(app, "edit.formatPainter", json!({"sticky": true}));
} else if fp.clicked() {
if fp_on {
app.session.format_painter = None;
} else {
act(app, "edit.formatPainter", json!({}));
}
}
});
sep(ui);
// Font
ui.vertical(|ui| {
ui.horizontal(|ui| {
font_combo(app, ui, &st);
size_combo(app, ui, &st);
let __k = icon_button(ui, Icon::Plus, t.text, "Increase Font Size", vec2(22.0, 23.0));
control_keytip(app, ui, &__k, "home.increaseFontSize");
if __k.clicked() {
act(app, "home.increaseFontSize", json!({}));
}
let __k = icon_button(ui, Icon::Minus, t.text, "Decrease Font Size", vec2(22.0, 23.0));
control_keytip(app, ui, &__k, "home.decreaseFontSize");
if __k.clicked() {
act(app, "home.decreaseFontSize", json!({}));
}
});
ui.horizontal(|ui| {
let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold");
control_keytip(app, ui, &__k, "home.bold");
if __k.clicked() {
act(app, "home.bold", json!({}));
}
let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic");
control_keytip(app, ui, &__k, "home.italic");
if __k.clicked() {
act(app, "home.italic", json!({}));
}
let (u, ua) = split_button(ui, Icon::Underline, None, "Underline");
control_keytip(app, ui, &ua, "home.underline");
if u {
act(app, "home.underline", json!({}));
}
egui::Popup::menu(&ua).show(|ui| {
menu_items(
app,
ui,
&[
("Underline", "home.underline", json!({"style": "single"})),
("Double Underline", "home.underline", json!({"style": "double"})),
],
);
});
let __k = toggle_button(ui, Icon::Strike, st.font.strike, "Strikethrough");
control_keytip(app, ui, &__k, "home.strikethrough");
if __k.clicked() {
act(app, "home.strikethrough", json!({}));
}
let (b, ba) = split_button(ui, Icon::Borders, None, "Borders");
control_keytip(app, ui, &ba, "home.borders");
if b {
act(app, "home.borders", json!({"preset": app.grid.last_border.clone()}));
}
ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets"));
egui::Popup::menu(&ba).show(|ui| {
for (label, preset) in [
("Bottom Border", "bottom"),
("Top Border", "top"),
("Left Border", "left"),
("Right Border", "right"),
("No Border", "none"),
("All Borders", "all"),
("Outside Borders", "outside"),
("Thick Outside Borders", "thickOutside"),
("Bottom Double Border", "doubleBottom"),
("Thick Bottom Border", "thickBottom"),
("Top and Bottom Border", "topBottom"),
("Top and Thick Bottom Border", "topThickBottom"),
("Top and Double Bottom Border", "topDoubleBottom"),
("Inside Borders", "inside"),
] {
if crate::border_preview::preset_button(ui, label, preset).clicked() {
app.grid.last_border = preset.to_string();
act(app, "home.borders", json!({"preset": preset}));
}
}
ui.separator();
if ui.button("More Borders…").clicked() {
app.open_dialog("formatCells", json!({"tab": "Border"}));
}
});
let fill = app.grid.last_fill.clone();
let fill_c = gridcraft_engine::model::Color::from_hex(&fill).and_then(|c| c.resolve(&Default::default())).map(theme::color32);
let (f, fa) = split_button(ui, Icon::Fill, fill_c, "Fill Color");
control_keytip(app, ui, &fa, "home.fillColor");
if f {
act(app, "home.fillColor", json!({"color": fill}));
}
let forced = invoke(app, "home.fillColor");
egui::Popup::menu(&fa)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.open_memory(popup_command(forced, fa.clicked()))
.show(|ui| {
if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") {
if c != "none" {
app.grid.last_fill = c.clone();
}
act(app, "home.fillColor", json!({"color": c}));
ui.close();
}
});
let fc = app.grid.last_font_color.clone();
let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32);
let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color");
control_keytip(app, ui, &ca, "home.fontColor");
if c {
act(app, "home.fontColor", json!({"color": fc}));
}
let forced = invoke(app, "home.fontColor");
egui::Popup::menu(&ca)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.open_memory(popup_command(forced, ca.clicked()))
.show(|ui| {
if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") {
if c != "none" {
app.grid.last_font_color = c.clone();
}
act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }}));
ui.close();
}
});
});
});
sep(ui);
// Alignment
ui.vertical(|ui| {
ui.horizontal(|ui| {
for (icon, v, id, tip) in [
(Icon::AlignTop, VAlign::Top, "home.alignTop", "Top Align"),
(Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"),
(Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"),
] {
let __k = toggle_button(ui, icon, st.align.v == v, tip);
control_keytip(app, ui, &__k, id);
if __k.clicked() {
act(app, id, json!({}));
}
}
let o = small_button(ui, Icon::Orientation, "", "Orientation", true);
control_keytip(app, ui, &o, "home.orientation");
let forced_o = invoke(app, "home.orientation");
egui::Popup::menu(&o).open_memory(popup_command(forced_o, o.clicked())).show(|ui| {
menu_items(
app,
ui,
&[
("Angle Counterclockwise", "home.orientation", json!({"angle": "counterclockwise"})),
("Angle Clockwise", "home.orientation", json!({"angle": "clockwise"})),
("Vertical Text", "home.orientation", json!({"angle": "vertical"})),
("Rotate Text Up", "home.orientation", json!({"angle": "up"})),
("Rotate Text Down", "home.orientation", json!({"angle": "down"})),
("Horizontal", "home.orientation", json!({"angle": 0})),
("-", "", json!(null)),
("Format Cell Alignment…", "dialog:formatCells", json!({"tab": "Alignment"})),
],
);
});
let wrap = toggle_button(ui, Icon::Wrap, st.align.wrap, "Wrap Text");
control_keytip(app, ui, &wrap, "home.wrapText");
if wrap.clicked() {
act(app, "home.wrapText", json!({}));
}
ui.label(egui::RichText::new(app.ui.language.tr("Wrap Text")).font(theme::ui_font(12.5)));
});
ui.horizontal(|ui| {
for (icon, h, id, tip) in [
(Icon::AlignLeft, HAlign::Left, "home.alignLeft", "Align Left"),
(Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"),
(Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"),
] {
let response = toggle_button(ui, icon, st.align.h == h, tip);
for sequence in keytip_table::control_badges(app.keytips.prefix().unwrap_or_default(), id) {
keytip(app, ui, sequence, response.rect.center_bottom());
}
if response.clicked() {
act(app, id, json!({}));
}
}
let __k = icon_button(ui, Icon::IndentDec, t.text, "Decrease Indent", vec2(24.0, 23.0));
control_keytip(app, ui, &__k, "home.decreaseIndent");
if __k.clicked() {
act(app, "home.decreaseIndent", json!({}));
}
let __k = icon_button(ui, Icon::IndentInc, t.text, "Increase Indent", vec2(24.0, 23.0));
control_keytip(app, ui, &__k, "home.increaseIndent");
if __k.clicked() {
act(app, "home.increaseIndent", json!({}));
}
let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center");
control_keytip(app, ui, &ma, "home.mergeCenter");
if m {
act(app, "home.mergeCenter", json!({}));
}
ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5)));
egui::Popup::menu(&ma).show(|ui| {
menu_items(
app,
ui,
&[
("Merge & Center", "home.mergeCenter", json!({})),
("Merge Across", "home.mergeAcross", json!({})),
("Merge Cells", "home.mergeCells", json!({})),
("Unmerge Cells", "home.unmergeCells", json!({})),
],
);
});
});
});
sep(ui);
// Number
ui.vertical(|ui| {
number_combo(app, ui, &st);
ui.horizontal(|ui| {
let (c, ca) = split_button(ui, Icon::Currency, None, "Accounting Number Format");
control_keytip(app, ui, &ca, "home.accounting");
if c {
act(app, "home.accounting", json!({}));
}
egui::Popup::menu(&ca).show(|ui| {
let mut items: Vec<_> = ACCOUNTING_SYMBOLS.iter().map(|(label, code)| (*label, "home.numberFormat", json!({"code": code}))).collect();
items.push(("-", "", json!(null)));
items.push(("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"})));
menu_items(app, ui, &items);
});
let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0));
control_keytip(app, ui, &__k, "home.percent");
if __k.clicked() {
act(app, "home.percent", json!({}));
}
let __k = icon_button(ui, Icon::Comma, t.text, "Comma Style", vec2(24.0, 23.0));
control_keytip(app, ui, &__k, "home.comma");
if __k.clicked() {
act(app, "home.comma", json!({}));
}
let __k = icon_button(ui, Icon::DecInc, t.text, "Increase Decimal", vec2(26.0, 23.0));
control_keytip(app, ui, &__k, "home.increaseDecimal");
if __k.clicked() {
act(app, "home.increaseDecimal", json!({}));
}
let __k = icon_button(ui, Icon::DecDec, t.text, "Decrease Decimal", vec2(26.0, 23.0));
control_keytip(app, ui, &__k, "home.decreaseDecimal");
if __k.clicked() {
act(app, "home.decreaseDecimal", json!({}));
}
});
});
sep(ui);
// Styles
let cf = big_button(ui, Icon::CondFormat, "Conditional\nFormatting", "Conditional Formatting", true);
anchor_keytip(app, ui, &cf, &["home.conditionalFormat", "home.manageRules", "home.clearRules"]);
egui::Popup::menu(&cf).show(|ui| cf_menu(app, ui));
let ft = big_button(ui, Icon::FormatTable, "Format\nas Table", "Format as Table", true);
anchor_keytip(app, ui, &ft, &["table.style"]);
egui::Popup::menu(&ft).show(|ui| table_gallery(app, ui, "home.formatAsTable"));
let cs = big_button(ui, Icon::CellStyles, "Cell\nStyles", "Cell Styles", true);
anchor_keytip(app, ui, &cs, &["home.newCellStyle"]);
egui::Popup::menu(&cs).show(|ui| cell_style_gallery(app, ui));
sep(ui);
// Cells
ui.vertical(|ui| {
let ins = small_button(ui, Icon::Insert, "Insert", "Insert", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&["dialog:insertCells", "home.insertRows", "home.insertColumns", "home.insertSheet"],
) {
keytip(app, ui, sequence, ins.rect.center_bottom());
}
egui::Popup::menu(&ins).show(|ui| {
menu_items(
app,
ui,
&[
("Insert Cells…", "dialog:insertCells", json!({})),
("Insert Sheet Rows", "home.insertRows", json!({})),
("Insert Sheet Columns", "home.insertColumns", json!({})),
("Insert Sheet", "home.insertSheet", json!({})),
],
);
});
let del = small_button(ui, Icon::Delete, "Delete", "Delete", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&["dialog:deleteCells", "home.deleteRows", "home.deleteColumns", "home.deleteSheet"],
) {
keytip(app, ui, sequence, del.rect.center_bottom());
}
egui::Popup::menu(&del).show(|ui| {
menu_items(
app,
ui,
&[
("Delete Cells…", "dialog:deleteCells", json!({})),
("Delete Sheet Rows", "home.deleteRows", json!({})),
("Delete Sheet Columns", "home.deleteColumns", json!({})),
("Delete Sheet", "home.deleteSheet", json!({})),
],
);
});
let fmt = small_button(ui, Icon::Format, "Format", "Format", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&[
"dialog:rowHeight",
"home.autofitRowHeight",
"dialog:columnWidth",
"home.autofitColumnWidth",
"dialog:defaultWidth",
"home.hideRows",
"home.hideColumns",
"home.unhideRows",
"home.unhideColumns",
"sheet.hide",
"sheet.unhide",
"dialog:renameSheet",
"dialog:moveSheet",
"dialog:protectSheet",
"home.lockCell",
"dialog:formatCells",
],
) {
keytip(app, ui, sequence, fmt.rect.center_bottom());
}
let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC"));
let mut format_popup = egui::Popup::menu(&fmt);
if format_keys {
format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true));
}
let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" };
format_popup.show(|ui| {
menu_items(
app,
ui,
&[
("Row Height…", "dialog:rowHeight", json!({})),
("AutoFit Row Height", "home.autofitRowHeight", json!({})),
("Column Width…", "dialog:columnWidth", json!({})),
("AutoFit Column Width", "home.autofitColumnWidth", json!({})),
("Default Width…", "dialog:defaultWidth", json!({})),
("-", "", json!(null)),
("Hide Rows", "home.hideRows", json!({})),
("Hide Columns", "home.hideColumns", json!({})),
("Unhide Rows", "home.unhideRows", json!({})),
("Unhide Columns", "home.unhideColumns", json!({})),
("Hide Sheet", "sheet.hide", json!({})),
("Unhide Sheet…", "sheet.unhide", json!({})),
("-", "", json!(null)),
("Rename Sheet", "dialog:renameSheet", json!({})),
("Move or Copy Sheet…", "dialog:moveSheet", json!({})),
("-", "", json!(null)),
(protect_label, "dialog:protectSheet", json!({})),
("Lock Cell", "home.lockCell", json!({})),
("Format Cells…", "dialog:formatCells", json!({})),
],
);
});
});
sep(ui);
// Editing
ui.vertical(|ui| {
ui.horizontal(|ui| {
let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum");
control_keytip(app, ui, &sa, "formulas.autoSum");
if s {
act(app, "formulas.autoSum", json!({}));
}
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
});
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&["edit.fillDown", "edit.fillRight", "edit.fillUp", "edit.fillLeft", "dialog:series", "edit.flashFill"],
) {
keytip(app, ui, sequence, fill.rect.center_bottom());
}
egui::Popup::menu(&fill).show(|ui| {
menu_items(
app,
ui,
&[
("Down", "edit.fillDown", json!({})),
("Right", "edit.fillRight", json!({})),
("Up", "edit.fillUp", json!({})),
("Left", "edit.fillLeft", json!({})),
("Series…", "dialog:series", json!({})),
("Flash Fill", "edit.flashFill", json!({})),
],
);
});
let clear = small_button(ui, Icon::Eraser, "", "Clear", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&["edit.clearAll", "edit.clearFormats", "edit.clearContents", "edit.clearComments", "edit.clearHyperlinks"],
) {
keytip(app, ui, sequence, clear.rect.center_bottom());
}
egui::Popup::menu(&clear).show(|ui| {
menu_items(
app,
ui,
&[
("Clear All", "edit.clearAll", json!({})),
("Clear Formats", "edit.clearFormats", json!({})),
("Clear Contents", "edit.clearContents", json!({})),
("Clear Comments and Notes", "edit.clearComments", json!({})),
("Clear Hyperlinks", "edit.clearHyperlinks", json!({})),
],
);
});
});
let sf = big_button(ui, Icon::SortFilter, "Sort &\nFilter", "Sort & Filter", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&["data.sortAscending", "data.sortDescending", "dialog:sort", "data.filter", "data.clearFilter", "data.reapply"],
) {
keytip(app, ui, sequence, sf.rect.center_bottom());
}
egui::Popup::menu(&sf).show(|ui| {
menu_items(
app,
ui,
&[
("Sort A to Z", "data.sortAscending", json!({})),
("Sort Z to A", "data.sortDescending", json!({})),
("Custom Sort…", "dialog:sort", json!({})),
("-", "", json!(null)),
("Filter", "data.filter", json!({})),
("Clear", "data.clearFilter", json!({})),
("Reapply", "data.reapply", json!({})),
],
);
});
let fs = big_button(ui, Icon::Find, "Find &\nSelect", "Find & Select", true);
for sequence in keytip_table::group_extension(
app.keytips.prefix().unwrap_or_default(),
&["dialog:find", "dialog:goTo", "dialog:goToSpecial", "edit.goToSpecial"],
) {
keytip(app, ui, sequence, fs.rect.center_bottom());
}
egui::Popup::menu(&fs).show(|ui| {
menu_items(
app,
ui,
&[
("Find…", "dialog:find", json!({})),
("Replace…", "dialog:find", json!({"replace": true})),
("Go To…", "dialog:goTo", json!({})),
("Go To Special…", "dialog:goToSpecial", json!({})),
("-", "", json!(null)),
("Formulas", "edit.goToSpecial", json!({"kind": "formulas"})),
("Notes", "edit.goToSpecial", json!({"kind": "notes"})),
("Conditional Formatting", "edit.goToSpecial", json!({"kind": "conditionalFormats"})),
("Constants", "edit.goToSpecial", json!({"kind": "constants"})),
("Data Validation", "edit.goToSpecial", json!({"kind": "dataValidation"})),
],
);
});
}
/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert
/// Function dialog.
pub(super) fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) {
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
if ui.button(f).clicked() {
act(app, "formulas.autoSum", json!({"function": f}));
}
}
ui.separator();
if ui.button("More Functions…").clicked() {
app.open_dialog("insertFunction", json!({}));
}
}
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let name = st.font.name.clone();
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
control_keytip(app, ui, &resp, "home.fontName");
let forced = invoke(app, "home.fontName");
let items: Vec<(&str, String)> = FONTS.iter().map(|f| (*f, f.to_string())).collect();
if let ComboResult::Picked(value) = editable_combo(ui, &resp, "font", &name, &items, forced) {
act(app, "home.fontName", json!({"name": value}));
}
}
pub const FONTS: &[&str] = &[
"Aptos",
"Aptos Narrow",
"Arial",
"Calibri",
"Cambria",
"Candara",
"Consolas",
"Courier New",
"Garamond",
"Georgia",
"Helvetica",
"Helvetica Neue",
"Segoe UI",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana",
];
/// Excel's size box: type a size and press Enter to apply exactly that number (the command refuses
/// sizes outside 1 to 409), Escape or clicking away reverts; the arrow opens the standard sizes.
fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let t = Tokens::get(ui.ctx());
let size = st.font.size;
let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") };
let field = egui::Id::unique("gridcraft.font_size_box");
let draft = field.with("draft");
let mut text = ui.data_mut(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| current.clone());
// One frame like the Font name box beside it (same fill, border, height and corners), with the
// text field and the dropdown arrow inside it, as in Excel.
let (frame, _) = ui.allocate_exact_size(vec2(52.0, 24.0), egui::Sense::hover());
// Painted once the field's focus and hover are known, but beneath the field and the arrow.
let bg = ui.painter().add(egui::Shape::Noop);
let text_rect = Rect::from_min_max(frame.min, egui::pos2(frame.right() - 16.0, frame.bottom()));
let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0));
let mut field_ui =
ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let out = egui::TextEdit::singleline(&mut text)
.id(field)
.font(theme::ui_font(12.5))
.frame(egui::Frame::NONE)
.desired_width(text_rect.width() - 12.0)
.show(&mut field_ui);
let resp = out.response;
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(app.ui.language.tr("Font Size")));
control_keytip(app, ui, &resp, "home.fontSize");
if invoke(app, "home.fontSize") {
ui.memory_mut(|m| m.request_focus(field));
}
if resp.gained_focus() {
// Typing replaces the size, as in Excel, whether the box was clicked or reached by keytip.
let mut state = out.state;
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()))));
state.store(ui.ctx(), field);
}
if resp.lost_focus() {
ui.data_mut(|d| d.remove::<String>(draft));
// Consume the Enter so the grid behind does not also move the active cell down.
if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter))
&& let Some(v) = text.trim().parse::<f64>().ok().filter(|v| v.is_finite())
{
act(app, "home.fontSize", json!({"size": v}));
}
} else if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, text));
} else {
ui.data_mut(|d| d.remove::<String>(draft));
}
let mut arrow_ui = ui.new_child(egui::UiBuilder::new().max_rect(arrow_rect));
let arrow = icon_button(&mut arrow_ui, Icon::Chevron, t.text_dim, "Font Size", arrow_rect.size());
let visuals = ui.style().visuals.clone();
let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive };
let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke };
ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside));
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Font Size"));
let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
let labels: Vec<String> = sizes.iter().map(|s| format!("{}", *s as i32)).collect();
let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect();
if let ComboResult::Picked(value) = list_combo(ui, &arrow, frame, "size", &current, &items)
&& let Ok(v) = value.parse::<f32>()
{
act(app, "home.fontSize", json!({"size": v}));
}
}
fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
let code = st.num_fmt.as_str().to_string();
let kind = gridcraft_engine::display::number_format(&code).kind();
let current = format!("{kind:?}");
let lang = app.ui.language;
let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default();
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(lang.tr(&current)).font(theme::ui_font(12.5))));
control_keytip(app, ui, &resp, "home.numberFormat");
let forced = invoke(app, "home.numberFormat");
let names = ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"];
let items: Vec<(&str, String)> = names
.iter()
.map(|n| {
let code = gridcraft_engine::cmd::format::format_code_for(n);
let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default();
(*n, format!("{:<12} {preview}", lang.tr(n)))
})
.chain(std::iter::once(("__more", format!("{:>12}", lang.tr("More Number Formats…")))))
.collect();
if let ComboResult::Picked(value) = editable_combo(ui, &resp, "number", &current, &items, forced) {
match value.as_str() {
"__more" => app.open_dialog("formatCells", json!({"tab": "Number"})),
n => act(app, "home.numberFormat", json!({"format": n})),
}
}
}
fn cf_menu(app: &mut SheetApp, ui: &mut Ui) {
ui.menu_button("Highlight Cells Rules", |ui| {
for (label, op) in [("Greater Than…", "greater"), ("Less Than…", "less"), ("Between…", "between"), ("Equal To…", "equal")] {
if ui.button(label).clicked() {
app.open_dialog("cfQuick", json!({"type": "cellIs", "operator": op, "title": label}));
}
}
if ui.button("Text that Contains…").clicked() {
app.open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"}));
}
if ui.button("A Date Occurring…").clicked() {
app.open_dialog("cfQuick", json!({"type": "timePeriod", "title": "A Date Occurring"}));
}
if ui.button("Duplicate Values…").clicked() {
act(app, "home.conditionalFormat", json!({"rule": {"type": "duplicate"}}));
}
});
ui.menu_button("Top/Bottom Rules", |ui| {
menu_items(
app,
ui,
&[
("Top 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10}})),
("Top 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "percent": true}})),
("Bottom 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true}})),
("Bottom 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true, "percent": true}})),
("Above Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage"}})),
("Below Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage", "below": true}})),
],
);
});
ui.menu_button("Data Bars", |ui| {
for (label, c) in
[("Blue", "#638EC6"), ("Green", "#63BE7B"), ("Red", "#F8696B"), ("Orange", "#FFB628"), ("Light Blue", "#008AEF"), ("Purple", "#D6007B")]
{
if ui.button(label).clicked() {
act(app, "home.conditionalFormat", json!({"rule": {"type": "dataBar", "color": c}}));
}
}
});
ui.menu_button("Color Scales", |ui| {
for (label, cols) in [
("Green - Yellow - Red", vec!["#63BE7B", "#FFEB84", "#F8696B"]),
("Red - Yellow - Green", vec!["#F8696B", "#FFEB84", "#63BE7B"]),
("Green - White - Red", vec!["#63BE7B", "#FCFCFF", "#F8696B"]),
("Blue - White - Red", vec!["#5A8AC6", "#FCFCFF", "#F8696B"]),
("White - Green", vec!["#FCFCFF", "#63BE7B"]),
("White - Red", vec!["#FCFCFF", "#F8696B"]),
] {
if ui.button(label).clicked() {
act(app, "home.conditionalFormat", json!({"rule": {"type": "colorScale", "colors": cols}}));
}
}
});
ui.menu_button("Icon Sets", |ui| {
for (label, set) in [
("3 Arrows", "3Arrows"),
("3 Traffic Lights", "3TrafficLights1"),
("3 Symbols", "3Symbols"),
("4 Ratings", "4Rating"),
("5 Arrows", "5Arrows"),
("5 Quarters", "5Quarters"),
] {
if ui.button(label).clicked() {
act(app, "home.conditionalFormat", json!({"rule": {"type": "iconSet", "set": set}}));
}
}
});
ui.separator();
if ui.button("New Rule…").clicked() {
app.open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"}));
}
ui.menu_button("Clear Rules", |ui| {
menu_items(
app,
ui,
&[
("Clear Rules from Selected Cells", "home.clearRules", json!({})),
("Clear Rules from Entire Sheet", "home.clearRules", json!({"sheet": true})),
],
);
});
if ui.button("Manage Rules…").clicked() {
app.open_dialog("manageRules", json!({}));
}
}
#[cfg(test)]
mod accounting_symbol_tests {
use super::*;
use gridcraft_engine::core::{DateSystem, Value};
use gridcraft_engine::numfmt::{NumberFormat, format_value};
#[test]
fn accounting_dropdown_offers_the_issue_currencies_with_excel_codes() {
// #1063: label, Excel's [$symbol-LCID] tag, and 1234.5 as Excel shows it (fill and the
// `_-` padding aside). The yen and won have no minor unit, so their codes show no decimals.
for (label, tag, shown) in [
("Rp Indonesian", "[$Rp-421]", "Rp1,234.50"),
("₹ English (India)", "[$₹-4009]", "₹1,234.50"),
("₩ Korean", "[$₩-412]", "₩1,235"),
("¥ Chinese (PRC)", "[$¥-804]", "¥1,234.50"),
("¥ Japanese", "[$¥-411]", "¥1,235"),
] {
let code = ACCOUNTING_SYMBOLS.iter().find(|(l, _)| *l == label).map(|(_, c)| *c);
let Some(code) = code else { panic!("{label} is not in the Accounting dropdown") };
assert!(code.split(';').take(3).all(|section| section.contains(tag)), "{label}: {code}");
let out = format_value(&Value::Number(1234.5), &NumberFormat::parse(code), DateSystem::D1900);
assert_eq!(out.text.trim(), shown, "{label}");
let symbol = tag.trim_start_matches("[$").split('-').next().unwrap_or_default();
assert_eq!(out.fill.map(|(c, at)| (c, out.text[..at].trim_start())), Some((' ', symbol)), "{label}");
}
}
}
+245
View File
@@ -0,0 +1,245 @@
use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep};
use super::*;
/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols.
pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false);
control_keytip(app, ui, &__k, "insert.pivotTable");
if __k.clicked() {
act(app, "insert.pivotTable", json!({}));
}
let __k = big_button(ui, Icon::Table, "Table", "Table", false);
control_keytip(app, ui, &__k, "insert.table");
if __k.clicked() {
act(app, "insert.table", json!({}));
}
sep(ui);
let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true);
anchor_keytip(app, ui, &pic, &["insert.picture"]);
pic.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Pictures"));
egui::Popup::menu(&pic).show(|ui| {
for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] {
if ui.button(label).clicked() {
app.open_dialog("insertPicture", json!({"placement": placement}));
ui.close();
}
}
});
let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true);
anchor_keytip(app, ui, &shapes, &["insert.shape"]);
egui::Popup::menu(&shapes).show(|ui| {
for (label, kind) in [
("Rectangle", "rectangle"),
("Rounded Rectangle", "roundedRectangle"),
("Oval", "ellipse"),
("Triangle", "triangle"),
("Line", "line"),
("Arrow", "arrow"),
("Text Box", "textBox"),
] {
if ui.button(label).clicked() {
act(app, "insert.shape", json!({"kind": kind}));
}
}
});
let icn = big_button(ui, Icon::Theme, "Icons", "Insert an icon", true);
anchor_keytip(app, ui, &icn, &["insert.icons"]);
egui::Popup::menu(&icn).show(|ui| {
ui.set_width(260.0);
egui::Grid::new("icon_lib").show(ui, |ui| {
for (i, (name, icon)) in icons::LIBRARY.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, Tokens::get(ui.ctx()).hover);
}
icons::paint(ui.painter(), r.shrink(6.0), *icon, Tokens::get(ui.ctx()).text);
if resp.on_hover_text(*name).clicked() {
act(app, "insert.icons", json!({"name": name}));
ui.close();
}
if (i + 1) % 6 == 0 {
ui.end_row();
}
}
});
});
sep(ui);
let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false);
control_keytip(app, ui, &__k, "insert.recommendedCharts");
if __k.clicked() {
// Menu-style: with no chart type, run_or_alert opens the Insert Chart dialog.
act(app, "insert.recommendedCharts", json!({}));
}
ui.vertical(|ui| {
ui.horizontal(|ui| {
for (icon, tip, cid, subs) in [
(
Icon::ChartBar,
"Column or Bar",
"insert.chart.column",
vec![
("Clustered Column", "column", ""),
("Stacked Column", "column", "stacked"),
("100% Stacked Column", "column", "stacked100"),
("Clustered Bar", "bar", ""),
("Stacked Bar", "bar", "stacked"),
("100% Stacked Bar", "bar", "stacked100"),
],
),
(
Icon::ChartLine,
"Line or Area",
"insert.chart.line",
vec![
("Line", "line", ""),
("Line with Markers", "line", "markers"),
("Stacked Line", "line", "stacked"),
("Area", "area", ""),
("Stacked Area", "area", "stacked"),
("100% Stacked Area", "area", "stacked100"),
],
),
(Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]),
] {
let r = small_button(ui, icon, "", tip, true);
control_keytip(app, ui, &r, cid);
let forced_c = invoke(app, cid);
egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| {
for (label, k, s) in &subs {
if ui.button(*label).clicked() {
act(app, "insert.chart", json!({"type": k, "subtype": s}));
}
}
});
}
});
ui.horizontal(|ui| {
for (icon, tip, cid, subs) in [
(
Icon::ChartScatter,
"Scatter or Bubble",
"insert.chart.scatter",
vec![("Scatter", "scatter", ""), ("Scatter with Lines", "scatter", "lines"), ("Bubble", "bubble", "")],
),
(Icon::Chart, "Statistic", "insert.chart.statistic", vec![("Histogram", "histogram", ""), ("Box & Whisker", "boxWhisker", "")]),
(
Icon::Theme,
"Hierarchy & more",
"insert.chart.hierarchy",
vec![
("Treemap", "treemap", ""),
("Sunburst", "sunburst", ""),
("Waterfall", "waterfall", ""),
("Funnel", "funnel", ""),
("Radar", "radar", ""),
("Stock", "stock", ""),
("Combo", "combo", ""),
],
),
] {
let r = small_button(ui, icon, "", tip, true);
control_keytip(app, ui, &r, cid);
let forced_c = invoke(app, cid);
egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| {
for (label, k, s) in &subs {
if ui.button(*label).clicked() {
act(app, "insert.chart", json!({"type": k, "subtype": s}));
}
}
});
}
});
});
sep(ui);
if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() {
act(app, "insert.slicer", json!({}));
}
sep(ui);
let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true);
anchor_keytip(app, ui, &sp, &["dialog:sparkline"]);
egui::Popup::menu(&sp).show(|ui| {
for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] {
if ui.button(label).clicked() {
app.open_dialog("sparkline", json!({"type": kind}));
}
}
});
sep(ui);
// Cell Controls.
if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() {
act(app, "insert.checkbox", json!({}));
}
sep(ui);
let __k = big_button(ui, Icon::Link, "Link", "Insert Link", false);
control_keytip(app, ui, &__k, "dialog:insertLink");
if __k.clicked() {
app.open_dialog("insertLink", json!({}));
}
let __k = big_button(ui, Icon::Comment, "Comment", "New Comment", false);
control_keytip(app, ui, &__k, "dialog:comment");
if __k.clicked() {
app.open_dialog("comment", json!({"threaded": true}));
}
sep(ui);
let __k = big_button(ui, Icon::TextBox, "Text\nBox", "Text Box", false);
control_keytip(app, ui, &__k, "insert.textBox");
if __k.clicked() {
act(app, "insert.textBox", json!({}));
}
let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false);
control_keytip(app, ui, &__k, "dialog:headerFooter");
if __k.clicked() {
// As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup).
crate::paper_view::start_editing(app);
}
let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true);
control_keytip(app, ui, &sym, "insert.symbol");
egui::Popup::menu(&sym).show(|ui| {
ui.set_width(260.0);
ui.horizontal_wrapped(|ui| {
for ch in [
"€", "£", "¥", "©", "®", "™", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Σ", "Ω", "°", "•", "…", "→", "←", "↑", "↓",
"✓", "★", "½", "¼",
] {
// No bundled face has ✓: the button paints a code-drawn check instead of a tofu box.
if crate::widgets::glyph_button(ui, ch.chars().next().unwrap_or('?'), &format!("Insert {ch}")).clicked() {
act(app, "insert.symbol", json!({"char": ch}));
}
}
});
});
}
pub(super) fn draw(app: &mut SheetApp, ui: &mut Ui) {
let tool = app.session.draw_tool.clone();
let __k = big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false);
control_keytip(app, ui, &__k, "draw.lasso");
if __k.clicked() {
act(app, "draw.lasso", json!({}));
}
sep(ui);
for (label, color) in [("Pen\nBlue", "#1F5FC9"), ("Pen\nBlack", "#000000"), ("Pen\nRed", "#D13B2F"), ("Highlighter", "#F2E33A")] {
let on = tool == "pen" && app.session.draw_color.eq_ignore_ascii_case(color);
let r = big_button(ui, Icon::Pen, label, "Draw with ink", false);
if on {
ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if r.clicked() {
let width = if label == "Highlighter" { 12.0 } else { 2.0 };
act(app, "draw.pen", json!({"on": !on, "color": color, "width": width}));
}
}
let er = big_button(ui, Icon::Eraser, "Eraser", "Erase ink strokes", false);
if tool == "eraser" {
ui.painter().rect_stroke(er.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if er.clicked() {
act(app, "draw.eraser", json!({}));
}
sep(ui);
let __k = big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false);
control_keytip(app, ui, &__k, "draw.inkToShape");
if __k.clicked() {
act(app, "draw.inkToShape", json!({}));
}
}
+158
View File
@@ -0,0 +1,158 @@
use super::controls::{act, anchor_keytip, checkbox_on, control_keytip, invoke, keytip, menu_items, sep};
use super::*;
pub(super) fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
let th = big_button(ui, Icon::Theme, "Themes", "Themes", true);
anchor_keytip(app, ui, &th, &["pageLayout.theme"]);
egui::Popup::menu(&th).show(|ui| {
for (name, colors) in gridcraft_engine::cmd::view::themes() {
ui.horizontal(|ui| {
for c in colors.iter().skip(4).take(6) {
let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());
ui.painter().rect_filled(r, 1.0, Color32::from_rgb((c >> 16) as u8, (c >> 8) as u8, *c as u8));
}
if ui.button(name).clicked() {
act(app, "pageLayout.theme", json!({"name": name}));
}
});
}
});
sep(ui);
let m = big_button(ui, Icon::Margins, "Margins", "Margins", true);
for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.margins", "dialog:pageSetup"]) {
keytip(app, ui, sequence, m.rect.center_bottom());
}
egui::Popup::menu(&m).show(|ui| {
menu_items(
app,
ui,
&[
("Normal", "pageLayout.margins", json!({"preset": "normal"})),
("Wide", "pageLayout.margins", json!({"preset": "wide"})),
("Narrow", "pageLayout.margins", json!({"preset": "narrow"})),
("Custom Margins…", "dialog:pageSetup", json!({})),
],
);
});
let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true);
control_keytip(app, ui, &o, "pageLayout.orientation");
egui::Popup::menu(&o).show(|ui| {
menu_items(
app,
ui,
&[
("Portrait", "pageLayout.orientation", json!({"orientation": "portrait"})),
("Landscape", "pageLayout.orientation", json!({"orientation": "landscape"})),
],
);
});
let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true);
control_keytip(app, ui, &s, "pageLayout.size");
egui::Popup::menu(&s).show(|ui| {
// B4/B5/B6 are the JIS sizes Japanese Excel uses; the ISO ones stay selectable by name.
for p in [
"Letter",
"Legal",
"Tabloid",
"Executive",
"A3",
"A4",
"A5",
"A6",
"B4",
"B5",
"B6",
"Japanese Postcard",
"Japanese Double Postcard",
"Japanese Envelope #3",
"Japanese Envelope #4",
"Japanese Envelope Kaku #2",
"Japanese Envelope Kaku #3",
"B4 (ISO)",
"B5 (ISO)",
"B6 (ISO)",
] {
if ui.button(p).clicked() {
act(app, "pageLayout.size", json!({"paper": p}));
}
}
});
let pa = big_button(ui, Icon::PrintArea, "Print\nArea", "Print Area", true);
anchor_keytip(app, ui, &pa, &["pageLayout.printArea"]);
egui::Popup::menu(&pa).show(|ui| {
menu_items(
app,
ui,
&[("Set Print Area", "pageLayout.printArea", json!({})), ("Clear Print Area", "pageLayout.printArea", json!({"clear": true}))],
);
});
let br = big_button(ui, Icon::PageBreak, "Breaks", "Page Breaks", true);
for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.breaks"]) {
keytip(app, ui, sequence, br.rect.center_bottom());
}
egui::Popup::menu(&br).show(|ui| {
menu_items(
app,
ui,
&[
("Insert Page Break", "pageLayout.breaks", json!({"insert": true})),
("Remove Page Break", "pageLayout.breaks", json!({"remove": true})),
("Reset All Page Breaks", "pageLayout.breaks", json!({"reset": true})),
],
);
});
let __k = big_button(ui, Icon::Sheet, "Print\nTitles", "Print Titles", false);
control_keytip(app, ui, &__k, "pageLayout.printTitles");
if __k.clicked() {
app.open_dialog("pageSetup", json!({"tab": "Sheet"}));
}
sep(ui);
let sh = app
.session
.active()
.and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings)))
.unwrap_or((true, true, false, false));
// These two checkboxes mean the screen view vs. the printed page. English shows "View" (as in
// Excel), but the key is distinct from the "View" tab so languages can word them apart.
let screen = app.ui.language.tr("Sheet Options|View");
let print = app.ui.language.tr("Print");
ui.vertical(|ui| {
ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small());
let mut v = sh.0;
let resp = ui.checkbox(&mut v, screen);
control_keytip(app, ui, &resp, "view.gridlines");
if resp.changed() || invoke(app, "view.gridlines") {
act(app, "view.gridlines", json!({"on": checkbox_on(resp.changed(), v)}));
}
let mut p = sh.2;
let resp = ui.checkbox(&mut p, print);
control_keytip(app, ui, &resp, "pageLayout.printGridlines");
if resp.changed() || invoke(app, "pageLayout.printGridlines") {
act(app, "pageLayout.printGridlines", json!({"on": checkbox_on(resp.changed(), p)}));
}
});
ui.vertical(|ui| {
ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small());
let mut v = sh.1;
let resp = ui.checkbox(&mut v, screen);
control_keytip(app, ui, &resp, "view.headings");
if resp.changed() || invoke(app, "view.headings") {
act(app, "view.headings", json!({"on": checkbox_on(resp.changed(), v)}));
}
let mut p = sh.3;
let resp = ui.checkbox(&mut p, print);
control_keytip(app, ui, &resp, "pageLayout.printHeadings");
if resp.changed() || invoke(app, "pageLayout.printHeadings") {
act(app, "pageLayout.printHeadings", json!({"on": checkbox_on(resp.changed(), p)}));
}
});
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false);
if rtl {
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
}
if r.clicked() {
act(app, "view.rightToLeft", json!({"on": !rtl}));
}
}
+82
View File
@@ -0,0 +1,82 @@
use super::controls::{act, anchor_keytip, control_keytip, menu_items, sep};
use super::*;
pub(super) fn review(app: &mut SheetApp, ui: &mut Ui) {
let __k = big_button(ui, Icon::Spell, "Spelling", "Spelling (F7)", false);
control_keytip(app, ui, &__k, "dialog:spelling");
if __k.clicked() {
app.open_dialog("spelling", json!({}));
}
let __k = big_button(ui, Icon::Calc, "Workbook\nStatistics", "Workbook Statistics", false);
control_keytip(app, ui, &__k, "statistics");
if __k.clicked() {
app.open_dialog("statistics", json!({}));
}
let __k = big_button(ui, Icon::Validation, "Check\nAccessibility", "Check Accessibility", false);
control_keytip(app, ui, &__k, "dialog:accessibility");
if __k.clicked() {
app.open_dialog("accessibility", json!({}));
}
sep(ui);
let __k = big_button(ui, Icon::Comment, "New\nComment", "New Comment", false);
control_keytip(app, ui, &__k, "dialog:comment");
if __k.clicked() {
app.open_dialog("comment", json!({"threaded": true}));
}
let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false);
control_keytip(app, ui, &__k, "review.deleteComment");
if __k.clicked() {
act(app, "review.deleteComment", json!({}));
}
ui.vertical(|ui| {
for (icon, label, tip, id) in
[(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")]
{
if small_button(ui, icon, label, tip, false).clicked() {
act(app, id, json!({}));
app.grid.ensure_visible = true;
}
}
});
let shown = app.session.comments_pane;
let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false);
if shown {
ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
if r.clicked() {
act(app, "review.showComments", json!({"on": !shown}));
if app.session.comments_pane {
app.grid.pane = None;
}
}
let notes = big_button(ui, Icon::Note, "Notes", "Notes", true);
anchor_keytip(app, ui, &notes, &["review.nextComment"]);
egui::Popup::menu(&notes).show(|ui| {
menu_items(
app,
ui,
&[
("New Note", "dialog:comment", json!({"threaded": false})),
("Show/Hide Note", "review.showNote", json!({})),
("Show All Notes", "review.showAllNotes", json!({})),
("-", "", json!({})),
("Convert to Comments", "review.convertToComments", json!({})),
],
);
});
sep(ui);
let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false);
let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false);
control_keytip(app, ui, &__k, "dialog:protectSheet");
if __k.clicked() {
app.open_dialog("protectSheet", json!({}));
}
let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false);
control_keytip(app, ui, &__k, "review.protectWorkbook");
if __k.clicked() {
crate::backstage::protect_workbook(app);
}
if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() {
app.open_dialog("allowEditRanges", json!({}));
}
}
+112
View File
@@ -0,0 +1,112 @@
use super::*;
/// Command-key shortcuts when the grid has focus.
pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
let shift = m.shift;
let id: Option<(&str, serde_json::Value)> = match key {
Key::B => Some(("home.bold", json!({}))),
Key::I => Some(("home.italic", json!({}))),
Key::U => Some(("home.underline", json!({}))),
Key::Z if shift => Some(("edit.redo", json!({}))),
Key::Z => Some(("edit.undo", json!({}))),
Key::Y => Some(("edit.redo", json!({}))),
Key::A => Some(("edit.selectAll", json!({}))),
Key::D => Some(("edit.fillDown", json!({}))),
Key::R => Some(("edit.fillRight", json!({}))),
Key::S if shift => Some(("file.saveAs", json!({}))),
Key::S => Some(("file.save", json!({}))),
Key::O => Some(("file.open", json!({}))),
Key::N => Some(("file.new", json!({}))),
Key::W => Some(("file.close", json!({}))),
Key::P => {
app.print_now(json!({}));
None
}
Key::K => {
app.open_dialog("insertLink", json!({}));
None
}
Key::F if shift => Some(("data.filter", json!({}))),
Key::F => {
app.open_dialog("find", json!({}));
None
}
Key::H => {
app.open_dialog("find", json!({"replace": true}));
None
}
Key::G => {
app.open_dialog("goTo", json!({}));
None
}
Key::T if shift => Some(("formulas.autoSum", json!({}))),
Key::T => Some(("insert.table", json!({}))),
Key::E => Some(("edit.flashFill", json!({}))),
Key::Num1 => {
app.open_dialog("formatCells", json!({}));
None
}
Key::Num9 if shift => Some(("home.unhideRows", json!({}))),
Key::Num9 => Some(("home.hideRows", json!({}))),
Key::Num0 if shift => Some(("home.unhideColumns", json!({}))),
Key::Num0 => Some(("home.hideColumns", json!({}))),
// As in Excel: with whole rows or whole columns selected, Ctrl+Shift+= / Ctrl+- insert or
// delete them at once; any other selection asks which way to shift.
Key::Minus => match whole_lines(app) {
Some(true) => Some(("home.deleteRows", json!({}))),
Some(false) => Some(("home.deleteColumns", json!({}))),
None => {
app.open_dialog("deleteCells", json!({}));
None
}
},
// Insert works on one area at a time, so several areas still go through the dialog.
Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) {
Some(true) => Some(("home.insertRows", json!({}))),
Some(false) => Some(("home.insertColumns", json!({}))),
None => {
app.open_dialog("insertCells", json!({}));
None
}
},
Key::Semicolon => {
// Insert today's date.
let text = app
.session
.active()
.map(|d| {
let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor();
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text
})
.unwrap_or_default();
app.begin_edit(Some(text), false);
None
}
Key::Backtick => Some(("formulas.showFormulas", json!({}))),
_ => None,
};
if let Some((id, p)) = id {
app.run_or_alert(id, p);
app.grid.ensure_visible = true;
}
}
/// Whether the selection is a single area.
fn single_area(app: &SheetApp) -> bool {
app.session.active().is_some_and(|d| d.selection.ranges.len() == 1)
}
/// `Some(true)` when every selected area is whole rows, `Some(false)` when every one is whole
/// columns, `None` otherwise (including the whole sheet).
fn whole_lines(app: &SheetApp) -> Option<bool> {
let sel = &app.session.active()?.selection;
let rows = sel.ranges.iter().all(|r| r.is_full_rows() && !r.is_full_cols());
let cols = sel.ranges.iter().all(|r| r.is_full_cols() && !r.is_full_rows());
if rows {
Some(true)
} else if cols {
Some(false)
} else {
None
}
}
+274
View File
@@ -0,0 +1,274 @@
use super::controls::{act, control_keytip};
use super::*;
/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the
/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take
/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit
/// vertically centred in it, with the controls beside them (#170).
const MAC: bool = cfg!(target_os = "macos");
pub const MAC_TITLE_BAR: f32 = 28.0;
/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the
/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the
/// gap after them from shrinking).
const MAC_LIGHT: f32 = 12.0;
const MAC_LIGHT_PITCH: f32 = 20.0;
const MAC_LIGHTS_LEFT: f32 = 10.0;
/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights
/// keep from the top of the window.
pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
/// Leave room for the macOS traffic lights when the content extends into the title bar.
pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 };
pub(super) const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 };
/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar.
pub(super) const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 };
/// Draws the title bar: drag/maximize, AutoSave toggle, Home/Save/Undo/Redo/More, search, share, comments and theme controls, with a centered truncated title.
pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let ch = TITLE_CONTROL_H;
egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| {
let rect = ui.max_rect();
// Dragging the empty title area moves the window.
let bg = ui.interact(rect, ui.scope_id().with("drag_title"), Sense::click_and_drag());
if bg.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if bg.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
ui.horizontal_centered(|ui| {
ui.add_space(TITLE_LEFT_PAD);
// AutoSave toggle (saves after each change when the workbook has a file).
ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim));
let on = app.ui.autosave;
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT });
ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside);
let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) };
ui.painter().circle_filled(knob, 5.0, if on { t.on_accent } else { t.text_dim });
if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() {
app.toggle_autosave();
}
ui.add_space(6.0);
let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch));
control_keytip(app, ui, &__k, "start");
if __k.clicked() {
app.open_dialog("start", json!({}));
}
if icon_button(ui, Icon::Save, t.text_dim, "Save", vec2(26.0, ch)).clicked()
// File operations must include the cell the user is still editing (as the File > Save menu item already does).
&& app.commit_edit(0, 0, false, false)
{
app.run_or_alert("file.save", json!({}));
}
// The commands' own availability: there must be a step, and no undo group may be open on
// the document (an action that spans several calls is still running).
let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok();
let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok();
// Named and marked enabled or not for assistive technology (and the UI tests).
let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch));
undo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_undo, "Undo"));
if undo.clicked() && can_undo {
app.run_or_alert("edit.undo", json!({}));
}
let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch));
egui::Popup::menu(&undo_list).show(|ui| {
let labels: Vec<String> = if can_undo {
app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default()
} else {
Vec::new()
};
if labels.is_empty() {
ui.label("Can't Undo");
}
for (i, l) in labels.iter().enumerate() {
if ui.button(format!("Undo {l}")).clicked() {
app.run_or_alert("edit.undo", json!({"steps": i + 1}));
}
}
});
let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch));
redo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_redo, "Redo"));
if redo.clicked() && can_redo {
app.run_or_alert("edit.redo", json!({}));
}
let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch));
egui::Popup::menu(&more).show(|ui| {
for (label, id) in [
("New Workbook", "file.new"),
("Open…", "file.open"),
("Save As…", "file.saveAs"),
("Sort A to Z", "data.sortAscending"),
("Calculate Now", "formulas.calculateNow"),
] {
if ui.button(label).clicked() {
app.run_or_alert(id, json!({}));
}
}
if ui.button("Print…").clicked() {
app.print_now(json!({}));
}
});
let left_end = ui.min_rect().right();
let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch));
control_keytip(app, ui, &__k, "commandSearch");
if __k.clicked() {
app.open_dialog("commandSearch", json!({}));
}
let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true);
egui::Popup::menu(&share).show(|ui| {
if ui.button("Export as PDF…").clicked() {
app.run_or_alert("file.exportPdf", json!({}));
}
for (label, fmt) in
[("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")]
{
if ui.button(label).clicked() {
app.open_dialog("saveCopy", json!({"format": fmt}));
}
}
});
let __k = icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch));
control_keytip(app, ui, &__k, "comments");
if __k.clicked() {
app.open_dialog("comments", json!({}));
}
// Quick light/dark switch (#194); right-click offers System as well. The same
// choice lives in File › Display theme and the View tab.
let lang = app.ui.language;
let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0));
let dark = ui.ctx().theme() == egui::Theme::Dark;
theme.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, dark, lang.tr("Dark Mode")));
if theme.clicked() {
act(app, "view.darkMode", json!({}));
}
theme.context_menu(|ui| theme_choices(app, ui));
ui.min_rect().left()
});
// Centred title, cut with "…" so it never runs under the controls on either side.
let title = app.session.active().map(|d| d.display_title()).unwrap_or_default();
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
let max_w = title_max_width(rect.center().x, left_end, right.inner);
paint_title(ui, rect.center(), &title, dirty, max_w, t.text);
});
});
}
/// Lay out the title truncated to fit `max_w` together with the unsaved marker (when `dirty`).
fn title_galleys(
painter: &egui::Painter,
title: &str,
dirty: bool,
max_w: f32,
color: Color32,
) -> (std::sync::Arc<egui::Galley>, Option<std::sync::Arc<egui::Galley>>) {
let font = theme::ui_bold(13.0);
let dot = dirty.then(|| painter.layout_no_wrap(" •".to_string(), font.clone(), color));
let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x);
let mut job = egui::text::LayoutJob::simple_singleline(title.to_string(), font, color);
job.wrap = egui::text::TextWrapping::truncate_at_width((max_w - dot_w).max(0.0));
(painter.layout_job(job), dot)
}
/// Gap kept between the title and the controls beside it.
const TITLE_GAP: f32 = 12.0;
/// The widest the centred title may be without reaching the controls that end at `left_end` or
/// start at `right_start`.
fn title_max_width(center_x: f32, left_end: f32, right_start: f32) -> f32 {
let half = (center_x - left_end).min(right_start - center_x) - TITLE_GAP;
(2.0 * half).max(0.0)
}
/// Paint the workbook title centred on `center`, truncated with "…" to `max_w`. The unsaved
/// marker stays visible however short the title gets.
fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32, color: Color32) {
let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, color);
let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x);
let w = galley.size().x + dot_w;
let left = center.x - w / 2.0;
let top = center.y - galley.size().y / 2.0;
let galley_w = galley.size().x;
ui.painter().galley(pos2(left, top), galley, color);
if let Some(dot) = dot {
ui.painter().galley(pos2(left + galley_w, top), dot, color);
}
}
#[cfg(test)]
mod title_bar_tests {
use super::*;
#[test]
fn mac_title_bar_centres_and_clears_the_traffic_lights() {
// The lights are centred in the bar, and content keeps the same gap after them.
let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT;
assert_eq!(MAC_CONTENT_LEFT - lights_end, margin);
}
#[test]
fn title_fits_between_the_controls() {
// Room limited by the nearer side, symmetric about the centre.
assert_eq!(title_max_width(500.0, 300.0, 900.0), 2.0 * (200.0 - TITLE_GAP));
assert_eq!(title_max_width(500.0, 100.0, 650.0), 2.0 * (150.0 - TITLE_GAP));
// Controls overlapping the centre leave no room rather than a negative width.
assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0);
}
/// Lay out a title in a real egui frame, returning (title text, title width, dot width).
fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) {
let ctx = egui::Context::default();
ctx.set_fonts(theme::font_definitions());
let mut out = (String::new(), 0.0, 0.0);
let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| {
let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE);
// The glyphs actually drawn: `galley.text()` is the untruncated source.
let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect();
out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x));
});
frame.textures_delta.clear(); // nothing consumes the atlas in a headless test
out
}
#[test]
fn long_title_is_truncated_with_an_ellipsis() {
let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4));
let (text, w, _) = layout_title(&long, false, 300.0);
assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}");
assert!(text.chars().count() < long.chars().count());
assert!(w <= 300.0, "title stays within its room: {w}");
}
#[test]
fn short_title_is_left_whole() {
let (text, _, _) = layout_title("Book1.xlsx", false, 300.0);
assert_eq!(text, "Book1.xlsx");
}
#[test]
fn unsaved_dot_survives_truncation() {
let long = "a very long workbook name ".repeat(20);
let (text, w, dot_w) = layout_title(&long, true, 200.0);
assert!(dot_w > 0.0, "the unsaved marker is laid out");
assert!(text.ends_with('…'));
assert!(w + dot_w <= 200.0, "title and marker fit together: {}", w + dot_w);
}
#[test]
fn no_room_draws_nothing_but_does_not_crash() {
let (_, w, _) = layout_title("Book1.xlsx", true, 0.0);
assert!(w <= 20.0, "a zero-width slot leaves at most the ellipsis: {w}");
}
}
+182
View File
@@ -0,0 +1,182 @@
use super::controls::{act, checkbox_on, control_keytip, invoke, keytip, menu_items, popup_command, sep};
use super::*;
/// Draws the View tab: view modes, ruler/gridlines/formula-bar/headings toggles, zoom, freeze panes, theme, animations, interface size and language.
pub(super) fn view(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let _ = t;
for (icon, label, cmd) in [
(Icon::Normal, "Normal", "view.normal"),
(Icon::PageBreak, "Page Break\nPreview", "view.pageBreakPreview"),
(Icon::PageLayout, "Page\nLayout", "view.pageLayout"),
] {
let __k = big_button(ui, icon, label, label, false);
control_keytip(app, ui, &__k, cmd);
if __k.clicked() {
act(app, cmd, json!({}));
}
}
sep(ui);
let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true));
let lang = app.ui.language;
let page_layout =
app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false);
// Two columns of two, as in Excel's Show group: four stacked rows overflow the ribbon body.
ui.vertical(|ui| {
// Rulers exist only in Page Layout view.
let mut ruler = !app.session.hide_ruler;
let ruler_resp = ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler")));
control_keytip(app, ui, &ruler_resp, "view.ruler");
if ruler_resp.changed() {
act(app, "view.ruler", json!({"on": ruler}));
}
let mut g = s.0;
let resp = ui.checkbox(&mut g, lang.tr("Gridlines"));
control_keytip(app, ui, &resp, "view.gridlines");
if resp.changed() {
act(app, "view.gridlines", json!({"on": g}));
}
});
ui.add_space(6.0);
ui.vertical(|ui| {
let mut fb = app.session.prefs.formula_bar;
let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar"));
control_keytip(app, ui, &resp, "view.formulaBar");
if resp.changed() || invoke(app, "view.formulaBar") {
act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)}));
}
let mut h = s.1;
let resp = ui.checkbox(&mut h, lang.tr("Headings"));
control_keytip(app, ui, &resp, "view.headings");
if resp.changed() {
act(app, "view.headings", json!({"on": h}));
}
});
sep(ui);
let __k = big_button(ui, Icon::Zoom, "Zoom", "Zoom…", false);
control_keytip(app, ui, &__k, "dialog:zoom");
if __k.clicked() {
app.open_dialog("zoom", json!({}));
}
let __k = big_button(ui, Icon::Search, "100%", "Zoom to 100%", false);
control_keytip(app, ui, &__k, "view.zoom");
if __k.clicked() || invoke(app, "view.zoom") {
act(app, "view.zoom", json!({"percent": 100}));
}
let __k = big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false);
control_keytip(app, ui, &__k, "view.zoomToSelection");
if __k.clicked() {
let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0)));
act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()}));
}
sep(ui);
let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false);
control_keytip(app, ui, &__k, "view.newWindow");
if __k.clicked() {
act(app, "view.newWindow", json!({}));
}
let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false);
control_keytip(app, ui, &__k, "view.arrangeAll");
if __k.clicked() {
app.open_dialog("arrangeAll", json!({}));
}
let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true);
for sequence in
keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"])
{
keytip(app, ui, sequence, fp.rect.center_bottom());
}
egui::Popup::menu(&fp).show(|ui| {
menu_items(
app,
ui,
&[
("Freeze Panes", "view.freezePanes", json!({})),
("Freeze Top Row", "view.freezeTopRow", json!({})),
("Freeze First Column", "view.freezeFirstColumn", json!({})),
("Unfreeze Panes", "view.unfreezePanes", json!({})),
],
);
});
switch_windows(app, ui);
ui.vertical(|ui| {
let label_response = ui.label(lang.tr("Display theme"));
let mode = app.ui.theme_mode();
let label = match mode {
"system" => "System",
"dark" => "Dark",
_ => "Light",
};
egui::ComboBox::from_id_salt("display_theme")
.selected_text(lang.tr(label))
.width(88.0)
.show_ui(ui, |ui| {
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
act(app, "view.theme", json!({"mode": value}));
}
}
})
.response
.labelled_by(label_response.id);
let mut animations = app.ui.animations;
let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there");
if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() {
act(app, "view.animations", json!({"on": animations}));
}
});
sep(ui);
// Interface size (issue #230): scales the whole window on top of the desktop's scale.
ui.vertical(|ui| {
let label = ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim));
let current = app.ui.interface_scale;
let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") };
egui::ComboBox::from_id_salt("ui_interface_size")
.width(120.0)
.selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5)))
.show_ui(ui, |ui| {
for v in [100, 75, 90, 110, 125, 150, 175, 200] {
if ui.selectable_label(v == current, text(v)).clicked() {
act(app, "view.interfaceScale", json!({"percent": v}));
}
}
})
.response
.labelled_by(label.id);
});
// Interface language: settings live in the View tab, like Excel's Options. The label follows
// the current language so it stays readable; the choices are always shown in their own script.
ui.vertical(|ui| {
let label = ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim));
egui::ComboBox::from_id_salt("ui_language")
.width(120.0)
.selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5)))
.show_ui(ui, |ui| {
for l in crate::i18n::Language::ALL {
if ui.selectable_label(l == lang, l.name()).clicked() {
act(app, "app.language.set", json!({"language": l.code()}));
}
}
})
.response
.labelled_by(label.id);
});
}
/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked;
/// choosing one brings it forward.
fn switch_windows(app: &mut SheetApp, ui: &mut Ui) {
let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true);
anchor.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Switch Windows"));
control_keytip(app, ui, &anchor, "view.switchWindows");
let forced = std::mem::take(&mut app.open_switch_windows) | invoke(app, "view.switchWindows");
egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| {
for w in app.open_windows() {
let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label()));
if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() {
act(app, "view.switchWindows", json!({"window": w.window}));
ui.close();
}
}
});
}