mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Merge PR #1612: Grid: a scrolled Table's header names and filter buttons in the column headings (#1423)
docs/ui-parity.md: kept HEAD header line, both change rows, newest first
This commit is contained in:
@@ -747,7 +747,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
// Headers.
|
// Headers.
|
||||||
if sh.show_headings {
|
if sh.show_headings {
|
||||||
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
|
let headings = table_headings(&geo, sh, sel.active);
|
||||||
|
paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings);
|
||||||
paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t);
|
paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t);
|
||||||
}
|
}
|
||||||
crate::outline::paint(&painter, &geo, sh, &t);
|
crate::outline::paint(&painter, &geo, sh, &t);
|
||||||
@@ -1408,10 +1409,7 @@ pub(crate) fn paint_quadrant(
|
|||||||
if !range.contains(c) {
|
if !range.contains(c) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let r = filter_button_rect(geo, sh, c);
|
paint_filter_button(p, filter_button_rect(geo, sh, c), active);
|
||||||
p.rect_filled(r, 2.0, Color32::from_gray(250));
|
|
||||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside);
|
|
||||||
crate::icons::paint(p, r.shrink(1.5), if active { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,6 +1421,67 @@ pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
|
|||||||
Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s))
|
Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A Table column heading that shows the column's header name in place of its letters.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TableHeading {
|
||||||
|
pub col: u32,
|
||||||
|
/// The table's header row, which the filter menu is keyed by.
|
||||||
|
pub header_row: u32,
|
||||||
|
pub name: String,
|
||||||
|
/// The heading slot over the column (logical coordinates).
|
||||||
|
pub rect: Rect,
|
||||||
|
/// The filter button, when the table shows filter buttons.
|
||||||
|
pub button: Option<Rect>,
|
||||||
|
/// The column is filtered (the button shows the funnel).
|
||||||
|
pub filtered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Table whose header names replace the column letters: the one holding the active cell,
|
||||||
|
/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423).
|
||||||
|
pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> {
|
||||||
|
// Page Layout view keeps the letters.
|
||||||
|
if geo.paper.is_some() || geo.header_h <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?;
|
||||||
|
let header = tb.range.start.row;
|
||||||
|
// A header row in the frozen rows is always on screen.
|
||||||
|
if header < geo.fr {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let top = geo.cells.top() + geo.frozen_h;
|
||||||
|
let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z;
|
||||||
|
(bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The headings of `heading_table`'s visible columns.
|
||||||
|
pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec<TableHeading> {
|
||||||
|
let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() };
|
||||||
|
let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for col in tb.range.start.col..=tb.range.end.col {
|
||||||
|
let w = sh.col_width(col) * geo.z;
|
||||||
|
let off = col - tb.range.start.col;
|
||||||
|
let Some(column) = tb.columns.get(off as usize) else { continue };
|
||||||
|
if w <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h));
|
||||||
|
// Clear of the 4-point column resize edge, and never wider than the column.
|
||||||
|
let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0);
|
||||||
|
let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s)));
|
||||||
|
let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off));
|
||||||
|
out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered });
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) {
|
||||||
|
p.rect_filled(r, 2.0, Color32::from_gray(250));
|
||||||
|
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside);
|
||||||
|
crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70));
|
||||||
|
}
|
||||||
|
|
||||||
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
||||||
/// first row's and first column's cells, the bottom and right edges from the last row's and last
|
/// first row's and first column's cells, the bottom and right edges from the last row's and last
|
||||||
/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`).
|
/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`).
|
||||||
@@ -1718,6 +1777,7 @@ pub(crate) fn paint_headers(
|
|||||||
sel: &gridcraft_engine::Selection,
|
sel: &gridcraft_engine::Selection,
|
||||||
t: &Tokens,
|
t: &Tokens,
|
||||||
quads: &[((u32, u32), (u32, u32), Rect)],
|
quads: &[((u32, u32), (u32, u32), Rect)],
|
||||||
|
headings: &[TableHeading],
|
||||||
) {
|
) {
|
||||||
let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0));
|
let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0));
|
||||||
// The headers' own area: inside the outline bands.
|
// The headers' own area: inside the outline bands.
|
||||||
@@ -1761,7 +1821,21 @@ pub(crate) fn paint_headers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let color = if col_sel(c) { t.header_sel_text } else { t.header_text };
|
let color = if col_sel(c) { t.header_sel_text } else { t.header_text };
|
||||||
pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color);
|
match headings.iter().find(|h| h.col == c) {
|
||||||
|
Some(h) => {
|
||||||
|
// The name is cut off at the button, as a cell's text is at its neighbour.
|
||||||
|
let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0;
|
||||||
|
let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect());
|
||||||
|
let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75);
|
||||||
|
pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color);
|
||||||
|
if let Some(b) = h.button {
|
||||||
|
paint_filter_button(&pc, b, h.filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom())));
|
let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom())));
|
||||||
@@ -2072,6 +2146,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let c = geo.col_at(sh, p.x);
|
let c = geo.col_at(sh, p.x);
|
||||||
|
// A Table heading's filter button, while the header names stand in for the letters.
|
||||||
|
if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) {
|
||||||
|
app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if point_header(app, wb, sh, RangeRef::cols(c, c)) {
|
if point_header(app, wb, sh, RangeRef::cols(c, c)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! A Table's header names and filter buttons stand in for the column letters while its header row
|
||||||
|
//! is scrolled out of view and the active cell is inside it, as in Excel (#1423).
|
||||||
|
|
||||||
|
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||||
|
use gridcraft_engine::Session;
|
||||||
|
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||||
|
use gridcraft_engine::model::Sheet;
|
||||||
|
use gridcraft_ui_egui::SheetApp;
|
||||||
|
use gridcraft_ui_egui::grid::{Geo, heading_table, table_headings};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||||
|
|
||||||
|
/// A Table on `range` (header row 3) with these header names and numbers below.
|
||||||
|
fn table(s: &mut Session, range: &str, names: &[&str]) {
|
||||||
|
let r = RangeRef::parse(range).unwrap();
|
||||||
|
let mut rows = vec![names.iter().map(|n| json!(n)).collect::<Vec<_>>()];
|
||||||
|
for i in r.start.row + 1..=r.end.row {
|
||||||
|
rows.push(names.iter().map(|_| json!(i)).collect());
|
||||||
|
}
|
||||||
|
s.execute("range.setValues", json!({"range": r.start.a1(), "values": rows})).unwrap();
|
||||||
|
s.execute("insert.table", json!({"range": range, "header": true})).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(active: &str) -> Session {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.new_workbook();
|
||||||
|
table(&mut s, "A3:E40", &["Bank", "Date", "Type", "Amount", "Ref"]);
|
||||||
|
s.execute("selection.set", json!({"cell": active})).unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sheet(s: &Session) -> Sheet {
|
||||||
|
s.active().unwrap().wb.active().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grid scrolled so that `row` is the first one under the frozen rows.
|
||||||
|
fn geo_at(sh: &Sheet, row: u32) -> Geo {
|
||||||
|
let fr = sh.freeze.map_or(0, |f| f.0);
|
||||||
|
let scroll = vec2(0.0, (sh.row_top(row) - sh.row_top(fr)) as f32);
|
||||||
|
Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)), scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(geo: &Geo, sh: &Sheet, active: &str) -> Vec<(u32, String)> {
|
||||||
|
table_headings(geo, sh, CellRef::parse(active).unwrap()).into_iter().map(|h| (h.col, h.name)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned(v: &[(u32, &str)]) -> Vec<(u32, String)> {
|
||||||
|
v.iter().map(|(c, n)| (*c, n.to_string())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_names_replace_the_letters_only_while_the_header_row_is_out_of_view() {
|
||||||
|
let s = session("C20");
|
||||||
|
let sh = sheet(&s);
|
||||||
|
let c20 = CellRef::parse("C20").unwrap();
|
||||||
|
assert!(heading_table(&geo_at(&sh, 0), &sh, c20).is_none(), "header row 3 on screen: letters");
|
||||||
|
assert!(heading_table(&geo_at(&sh, 2), &sh, c20).is_none(), "header row 3 at the top: letters");
|
||||||
|
assert_eq!(heading_table(&geo_at(&sh, 3), &sh, c20).map(|t| t.range.a1()).as_deref(), Some("A3:E40"));
|
||||||
|
assert_eq!(names(&geo_at(&sh, 10), &sh, "C20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type"), (3, "Amount"), (4, "Ref")]));
|
||||||
|
// Scrolled past the table's last row, the letters come back.
|
||||||
|
assert!(heading_table(&geo_at(&sh, 40), &sh, c20).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_active_cell_outside_the_table_keeps_the_letters() {
|
||||||
|
let s = session("G20");
|
||||||
|
let sh = sheet(&s);
|
||||||
|
// The table's rows fill the screen, so only where the active cell is tells the two apart.
|
||||||
|
assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("G20").unwrap()).is_none());
|
||||||
|
assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("E20").unwrap()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_header_row_in_the_frozen_pane_keeps_the_letters() {
|
||||||
|
let mut s = session("C20");
|
||||||
|
s.execute("view.freezePanes", json!({"cell": "A4"})).unwrap();
|
||||||
|
let sh = sheet(&s);
|
||||||
|
assert_eq!(sh.freeze, Some((3, 0)));
|
||||||
|
assert!(heading_table(&geo_at(&sh, 10), &sh, CellRef::parse("C20").unwrap()).is_none());
|
||||||
|
|
||||||
|
// Frozen above the header row, the header row scrolls under the frozen rows.
|
||||||
|
s.execute("view.freezePanes", json!({"cell": "A2"})).unwrap();
|
||||||
|
let sh = sheet(&s);
|
||||||
|
assert_eq!(sh.freeze, Some((1, 0)));
|
||||||
|
assert!(heading_table(&geo_at(&sh, 2), &sh, CellRef::parse("C20").unwrap()).is_none());
|
||||||
|
assert!(heading_table(&geo_at(&sh, 3), &sh, CellRef::parse("C20").unwrap()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_two_tables_the_names_come_from_the_active_cells_table() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.new_workbook();
|
||||||
|
table(&mut s, "A3:C40", &["Bank", "Date", "Type"]);
|
||||||
|
table(&mut s, "E3:G40", &["Region", "Units", "Price"]);
|
||||||
|
let sh = sheet(&s);
|
||||||
|
let geo = geo_at(&sh, 10);
|
||||||
|
assert_eq!(names(&geo, &sh, "F20"), owned(&[(4, "Region"), (5, "Units"), (6, "Price")]));
|
||||||
|
assert_eq!(names(&geo, &sh, "B20"), owned(&[(0, "Bank"), (1, "Date"), (2, "Type")]));
|
||||||
|
assert_eq!(names(&geo, &sh, "D20"), owned(&[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hidden_columns_leave_the_other_names_over_their_own_columns() {
|
||||||
|
let mut s = session("C20");
|
||||||
|
s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap();
|
||||||
|
let sh = sheet(&s);
|
||||||
|
assert_eq!(sh.col_width(1), 0.0);
|
||||||
|
let geo = geo_at(&sh, 10);
|
||||||
|
let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap());
|
||||||
|
let got: Vec<(u32, &str)> = headings.iter().map(|h| (h.col, h.name.as_str())).collect();
|
||||||
|
assert_eq!(got, [(0, "Bank"), (2, "Type"), (3, "Amount"), (4, "Ref")]);
|
||||||
|
for h in &headings {
|
||||||
|
let cell = geo.cell_rect(&sh, CellRef::new(10, h.col));
|
||||||
|
assert_eq!((h.rect.left(), h.rect.right()), (cell.left(), cell.right()), "{} sits over column {}", h.name, h.col);
|
||||||
|
let b = h.button.unwrap();
|
||||||
|
assert!(b.left() > cell.left() && b.right() < cell.right() && b.top() >= geo.head_top() && b.bottom() <= geo.cells.top());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click(h: &mut Harness, at: Pos2) {
|
||||||
|
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||||
|
h.input_mut().events.extend([Event::PointerMoved(at), press(true)]);
|
||||||
|
h.step();
|
||||||
|
h.input_mut().events.push(press(false));
|
||||||
|
h.run_steps(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_button_in_a_replaced_heading_opens_that_columns_filter_menu() {
|
||||||
|
let mut s = session("C20");
|
||||||
|
s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap();
|
||||||
|
s.execute("selection.set", json!({"cell": "C20"})).unwrap();
|
||||||
|
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||||
|
|ui, app: &mut SheetApp| {
|
||||||
|
app.logic(ui.ctx());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
SheetApp::new(s, Default::default()),
|
||||||
|
);
|
||||||
|
h.run_steps(4);
|
||||||
|
let sh = sheet(&h.state().session);
|
||||||
|
let scroll = sh.row_top(10) as f32;
|
||||||
|
h.state_mut().view_mut().unwrap().scroll.y = scroll;
|
||||||
|
h.run_steps(2);
|
||||||
|
if let Ok(path) = std::env::var("GRIDCRAFT_TABLE_HEADINGS_SCREENSHOT") {
|
||||||
|
h.render().unwrap().save(path).unwrap();
|
||||||
|
}
|
||||||
|
let app = h.state();
|
||||||
|
let geo = Geo::new(&sh, app.grid.rect.unwrap(), app.view().scroll);
|
||||||
|
let headings = table_headings(&geo, &sh, CellRef::parse("C20").unwrap());
|
||||||
|
let amount = headings.iter().find(|x| x.name == "Amount").unwrap();
|
||||||
|
assert_eq!(amount.col, 3);
|
||||||
|
|
||||||
|
click(&mut h, amount.button.unwrap().center());
|
||||||
|
let menu = h.state().grid.filter_menu.map(|(c, r, _)| (c, r));
|
||||||
|
assert_eq!(menu, Some((3, 2)), "the menu of column D, keyed by the table's header row");
|
||||||
|
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C20", "the button doesn't select");
|
||||||
|
|
||||||
|
// Beside the button the heading still selects the whole column.
|
||||||
|
h.state_mut().grid.filter_menu = None;
|
||||||
|
click(&mut h, pos2(amount.rect.left() + 6.0, amount.rect.center().y));
|
||||||
|
assert_eq!(h.state().grid.filter_menu, None);
|
||||||
|
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "D:D");
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ this logic, with one undo step.
|
|||||||
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog |
|
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog |
|
||||||
| Reorder selected whole columns by dragging their headers | done (10-11) |
|
| Reorder selected whole columns by dragging their headers | done (10-11) |
|
||||||
| Freeze panes, split panes | freeze done; split to verify |
|
| Freeze panes, split panes | freeze done; split to verify |
|
||||||
|
| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) |
|
||||||
| Copy as HTML table for other apps | done (#36, 10-10) |
|
| Copy as HTML table for other apps | done (#36, 10-10) |
|
||||||
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
|
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
|
||||||
| Move and resize charts, pictures and shapes | charts can't be resized (#190) |
|
| Move and resize charts, pictures and shapes | charts can't be resized (#190) |
|
||||||
@@ -164,6 +165,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
|
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
|
||||||
| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) |
|
| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) |
|
||||||
|
| 2026-10-16 | minor | With the active cell in a Table whose header row is scrolled out of view, the column headings show the header names and their filter buttons in place of the letters (#1423) |
|
||||||
| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) |
|
| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) |
|
||||||
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
|
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
|
||||||
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
|
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
|
||||||
|
|||||||
Reference in New Issue
Block a user