mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
349 lines
15 KiB
Rust
349 lines
15 KiB
Rust
//! The worksheet grid: virtualised painting of only the visible cells, frozen panes,
|
||
//! headers, selection, fill handle, column/row resizing, text overflow, borders, conditional
|
||
//! formats, tables, comments, filters, sparklines, floating charts/pictures/shapes and the
|
||
//! in-cell editor.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||
use gridcraft_engine::cf::{CfCache, CfLook};
|
||
use gridcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||
use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, PatternType, Sheet, Style, Underline, VAlign, Workbook};
|
||
use serde_json::json;
|
||
|
||
use crate::SheetApp;
|
||
use crate::editor::EditState;
|
||
use crate::theme::{self, Tokens};
|
||
|
||
/// A painted pane: its row span, column span and screen rectangle.
|
||
pub(crate) type PaneQuad = ((u32, u32), (u32, u32), Rect);
|
||
/// Cached Page Break Preview ranges, keyed by (doc uid, revision, sheet).
|
||
type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>);
|
||
/// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot).
|
||
type DetailsCache = ((u64, u64, usize, usize), Option<std::collections::HashSet<(u32, u32)>>);
|
||
|
||
mod active_row;
|
||
pub use active_row::active_row_band;
|
||
mod cell_paint;
|
||
mod cursor;
|
||
mod editor;
|
||
mod geo;
|
||
mod interact;
|
||
mod keyboard;
|
||
mod menus;
|
||
mod quadrant;
|
||
mod selection;
|
||
mod show;
|
||
mod tables;
|
||
|
||
pub(crate) use cell_paint::paint_border;
|
||
#[cfg(test)]
|
||
pub(crate) use cell_paint::{cell_text_y, rotated_text_pos, wrapped_cell_text_y};
|
||
#[cfg(test)]
|
||
use cell_paint::{center_across_end, display_text, look, numfmt_color, painted_halign};
|
||
pub use editor::validation_arrow;
|
||
#[cfg(test)]
|
||
use editor::{editor_slack, editor_width, list_picker_height};
|
||
pub use geo::Geo;
|
||
pub use interact::{follow_link, freeze_handles, point_move};
|
||
pub(crate) use quadrant::{QuadrantPaint, paint_quadrant};
|
||
pub use selection::selection_fill;
|
||
pub(crate) use selection::{SelectionPaintInput, paint_selection};
|
||
pub(crate) use show::horizontal_scrollbar;
|
||
pub use show::show;
|
||
#[cfg(test)]
|
||
use tables::filter_table_range;
|
||
pub use tables::{TableHeading, filter_button_rect, heading_table, table_headings};
|
||
|
||
pub const COL_HEADER_H: f32 = 21.0;
|
||
/// Sheet units are 96-dpi pixels (as in XLSX); on screen one unit is drawn at this many points,
|
||
/// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall).
|
||
pub const DISPLAY_SCALE: f32 = 0.8;
|
||
|
||
/// The zoom percentages a Ctrl+wheel notch steps between: four per doubling (about 20% apart),
|
||
/// so 25, 50, 100, 200 and 400 are on it. Excel steps 15 points a notch instead; the status bar's
|
||
/// Zoom In / Zoom Out buttons keep their 10% steps.
|
||
pub const WHEEL_ZOOM_LADDER: [u16; 22] = [10, 12, 15, 18, 21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400];
|
||
|
||
/// Seconds without Ctrl+wheel input after which part of a notch turned so far is forgotten.
|
||
const WHEEL_ZOOM_IDLE: f64 = 0.75;
|
||
|
||
/// The zoom one Ctrl+wheel notch goes to from `zoom`: the next step on [`WHEEL_ZOOM_LADDER`] in
|
||
/// that direction, skipping a step less than 5% away (121% out goes to 100%, not 120%), but never
|
||
/// 100% or the 10% / 400% limits (99% or 101% towards 100% goes to 100%).
|
||
pub fn wheel_zoom_step(zoom: u16, zoom_in: bool) -> u16 {
|
||
let zoom = zoom.clamp(10, 400);
|
||
let z = u32::from(zoom);
|
||
let far = |l: u32| if zoom_in { l * 100 >= z * 105 } else { l * 105 <= z * 100 };
|
||
let mut ahead = WHEEL_ZOOM_LADDER.iter().copied().filter(|&l| if zoom_in { l > zoom } else { l < zoom }).collect::<Vec<_>>();
|
||
if !zoom_in {
|
||
ahead.reverse();
|
||
}
|
||
let limit = ahead.last().copied().unwrap_or(zoom);
|
||
ahead.into_iter().find(|&l| l == 100 || far(u32::from(l))).unwrap_or(limit)
|
||
}
|
||
|
||
/// One frame of the zoom gliding from `from` to `to` percent: the share `t` (0–1) of the way,
|
||
/// measured as a ratio so zooming in and out feel alike, and at least one percent, so it arrives.
|
||
pub fn zoom_glide_step(from: u16, to: u16, t: f32) -> u16 {
|
||
let (f, g) = (f32::from(from.max(1)), f32::from(to.max(1)));
|
||
let next = (f * (g / f).powf(t.clamp(0.0, 1.0))).round();
|
||
let next = if next.is_finite() { next.clamp(f32::from(from.min(to)), f32::from(from.max(to))) as u16 } else { to };
|
||
match (next == from, from.cmp(&to)) {
|
||
(true, std::cmp::Ordering::Less) => from + 1,
|
||
(true, std::cmp::Ordering::Greater) => from - 1,
|
||
_ => next,
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub enum Drag {
|
||
#[default]
|
||
None,
|
||
Select,
|
||
Rows(u32),
|
||
Cols(u32),
|
||
/// Point mode over the row headers: the formula's reference runs from this row to the pointer.
|
||
PointRows(u32),
|
||
/// Point mode over the column headers: the formula's reference runs from this column to the pointer.
|
||
PointCols(u32),
|
||
/// Reordering one contiguous whole-column selection by its header.
|
||
MoveCols {
|
||
source: RangeRef,
|
||
before: Option<u32>,
|
||
},
|
||
ResizeCol {
|
||
col: u32,
|
||
start: f32,
|
||
orig: f32,
|
||
},
|
||
ResizeRow {
|
||
row: u32,
|
||
start: f32,
|
||
orig: f32,
|
||
},
|
||
/// Dragging the fill handle; `menu` when the right button drags it (the fill menu opens on release).
|
||
Fill {
|
||
target: Option<RangeRef>,
|
||
menu: bool,
|
||
},
|
||
/// Dragging a freeze handle: `cols` for the column bar, `to` the line count it would freeze.
|
||
Freeze {
|
||
cols: bool,
|
||
to: Option<u32>,
|
||
},
|
||
/// Moving the selection by its border (cut & paste).
|
||
Move {
|
||
grab: CellRef,
|
||
to: Option<CellRef>,
|
||
},
|
||
/// Moving a floating object, or resizing it from one of its eight handles.
|
||
Object {
|
||
obj: gridcraft_engine::model::ObjRef,
|
||
start: Pos2,
|
||
orig: (CellRef, f32, f32, f32, f32),
|
||
handle: Option<crate::objects::Handle>,
|
||
/// This drag has made its undo step; later frames merge into it, and never into the
|
||
/// step of an earlier drag.
|
||
stepped: bool,
|
||
},
|
||
}
|
||
|
||
/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo
|
||
/// another way. It only applies while nothing else changed the workbook (`revision`) and the
|
||
/// filled cells are still selected.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct FillTag {
|
||
pub src: RangeRef,
|
||
pub target: RangeRef,
|
||
pub uid: u64,
|
||
pub sheet: usize,
|
||
pub revision: u64,
|
||
pub undo_len: usize,
|
||
}
|
||
|
||
/// Trace Precedents, Trace Dependents and Trace Error arrows: they stay on the cells they were
|
||
/// traced from while the selection moves. They go away when anything but a zoom changes the
|
||
/// workbook (`revision`): Excel removes them on edits to the traced formula and on inserted,
|
||
/// deleted or moved cells, this also on a new value in a precedent.
|
||
pub struct Trace {
|
||
pub uid: u64,
|
||
pub revision: u64,
|
||
pub arrows: Vec<TraceArrow>,
|
||
}
|
||
|
||
/// One trace arrow, from a cell or range (boxed) to a cell, each on its sheet. An arrow is drawn
|
||
/// on a sheet that holds both its ends.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct TraceArrow {
|
||
pub from: (usize, RangeRef),
|
||
pub to: (usize, RangeRef),
|
||
/// A link between two cells showing the error Trace Error follows, drawn in the error colour.
|
||
pub error: bool,
|
||
}
|
||
|
||
impl Trace {
|
||
/// The arrows of a trace of the active cell, from the command's result: `{"sheet", "range"}`
|
||
/// precedents (Trace Precedents), `{"sheet", "cell"}` dependents (Trace Dependents), or
|
||
/// `{"arrows": [{"from", "to", "kind"}]}` (Trace Error). Ends on a sheet the workbook no
|
||
/// longer has are left out.
|
||
pub fn new(app: &SheetApp, result: serde_json::Value) -> Option<Trace> {
|
||
let d = app.session.active()?;
|
||
let traced = (d.wb.active_sheet(), RangeRef::new(d.selection.active, d.selection.active));
|
||
let end = |v: &serde_json::Value| {
|
||
let sheet = match v["sheet"].as_str() {
|
||
Some(name) => d.wb.sheet_index(name)?,
|
||
None => traced.0,
|
||
};
|
||
Some((sheet, RangeRef::parse(v["range"].as_str().or(v["cell"].as_str())?)?))
|
||
};
|
||
let arrows = match result.get("arrows").and_then(|a| a.as_array()) {
|
||
Some(list) => {
|
||
list.iter().filter_map(|a| Some(TraceArrow { from: end(&a["from"])?, to: end(&a["to"])?, error: a["kind"] == "error" })).collect()
|
||
}
|
||
None => result
|
||
.as_array()
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|it| {
|
||
let (from, to) = if it.get("range").is_some() { (end(it)?, traced) } else { (traced, end(it)?) };
|
||
Some(TraceArrow { from, to, error: false })
|
||
})
|
||
.collect(),
|
||
};
|
||
Some(Trace { uid: d.uid, revision: d.revision, arrows })
|
||
}
|
||
|
||
/// The active document's uid and revision, to hand [`Trace::keep_through_zoom`] after a zoom.
|
||
pub fn key(app: &SheetApp) -> Option<(u64, u64)> {
|
||
app.session.active().map(|d| (d.uid, d.revision))
|
||
}
|
||
|
||
/// Zooming changes the workbook (its revision) but moves no cell: arrows that were shown
|
||
/// before a zoom stay after it, as in Excel (#3097). `before` is [`Trace::key`] from then.
|
||
pub fn keep_through_zoom(app: &mut SheetApp, before: Option<(u64, u64)>) {
|
||
let now = Trace::key(app);
|
||
if let Some(t) = app.grid.trace.as_mut()
|
||
&& before == Some((t.uid, t.revision))
|
||
&& let Some((uid, revision)) = now
|
||
&& uid == t.uid
|
||
{
|
||
t.revision = revision;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct GridState {
|
||
pub drag: Drag,
|
||
pub ensure_visible: bool,
|
||
/// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell
|
||
/// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves).
|
||
pub reveal_moving_end: bool,
|
||
/// With `ensure_visible`: the cell an arrow or paging key moved from (the moving end when
|
||
/// extending), set only by those keys. A move from the scrolled rows or columns into the
|
||
/// frozen ones scrolls them back to their top or left.
|
||
pub reveal_from: Option<CellRef>,
|
||
pub last_title: Option<String>,
|
||
pub drag_select: bool,
|
||
pub rect: Option<Rect>,
|
||
pub cells_rect: Option<Rect>,
|
||
/// Column and row of the clicked header cell, and where to open the menu.
|
||
pub filter_menu: Option<(u32, u32, Pos2)>,
|
||
pub context_menu: Option<Pos2>,
|
||
/// The frame a right-click opened `context_menu` in: that click's own press doesn't close it.
|
||
pub context_menu_frame: u64,
|
||
/// After a right-drag of the fill handle: the source, the dragged range and where to open the menu.
|
||
pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>,
|
||
/// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells.
|
||
pub fill_tag: Option<FillTag>,
|
||
/// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id.
|
||
pub object_menu: Option<(Pos2, &'static str, u32)>,
|
||
pub marching_phase: f32,
|
||
/// The selection outline on its way to a new selection.
|
||
pub glide: crate::selection_glide::SelectionGlide,
|
||
pub last_click: Option<(CellRef, f64)>,
|
||
pub hover_cell: Option<CellRef>,
|
||
pub last_autosave: f64,
|
||
pub last_border: String,
|
||
pub last_fill: String,
|
||
pub last_font_color: String,
|
||
pub trace: Option<Trace>,
|
||
pub renaming_tab: Option<usize>,
|
||
pub rename_text: String,
|
||
pub list_picker: Option<CellRef>,
|
||
/// Open task pane: comments, watch, selection, formatChart.
|
||
pub pane: Option<String>,
|
||
/// Ink stroke being drawn (sheet points).
|
||
pub ink: Vec<[f32; 2]>,
|
||
/// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet).
|
||
pub pages: Option<PagesCache>,
|
||
/// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot):
|
||
/// it scans the whole source list, far too slow to repeat every frame (#2113).
|
||
pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>,
|
||
/// The cells of a PivotTable report where the cell menu offers Show Details, relative to the
|
||
/// report's top-left and cached per (doc uid, revision, sheet, pivot): finding them computes
|
||
/// the whole report. `None` when the report can't be computed or is out of date: every cell
|
||
/// of it offers Show Details, and running it says why.
|
||
pub show_details_cells: Option<DetailsCache>,
|
||
pub header_menu: Option<(Pos2, bool)>,
|
||
/// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns).
|
||
pub paper: Option<crate::paper_view::PaperCache>,
|
||
/// Header or footer being edited in place (Page Layout view).
|
||
pub hf_edit: Option<crate::paper_view::HfEdit>,
|
||
/// A page margin being dragged on a ruler (Page Layout view); committed on release.
|
||
pub ruler_drag: Option<crate::paper_view::RulerDrag>,
|
||
/// Insert › Header & Footer: start editing the first page's header once Page Layout view is up.
|
||
pub hf_start: bool,
|
||
/// The grid geometry of the last frame (hit-testing for agents and tests).
|
||
pub geo: Option<Geo>,
|
||
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
|
||
pub hscroll: Option<Rect>,
|
||
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
|
||
pub tab_split: Option<Rect>,
|
||
/// The share of the tab bar each workbook's tabs (and New sheet) take after the handle was
|
||
/// dragged, by document uid. A workbook without one gives its tabs up to 60%.
|
||
pub tab_shares: std::collections::HashMap<u64, f32>,
|
||
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
|
||
pub wheel_zoom: Option<WheelZoom>,
|
||
/// A one-finger touch drag in progress: it pans the sheet instead of selecting.
|
||
pub touch_pan: bool,
|
||
/// What the macro step editor shows on the grid (Check, debugging).
|
||
pub macro_overlay: Option<crate::macro_editor::MacroOverlay>,
|
||
}
|
||
|
||
/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and
|
||
/// fine-grained wheels send parts of one, so the turning is counted in notches, and each whole
|
||
/// notch steps [`WHEEL_ZOOM_LADDER`]. With animations on the zoom then glides to that step.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct WheelZoom {
|
||
pub place: (u64, usize),
|
||
/// Turned so far towards the next step, in notches (+ in, − out).
|
||
pub notches: f32,
|
||
/// When `notches` last changed.
|
||
pub time: f64,
|
||
/// The step the zoom is gliding to, and the percentage set last on the way.
|
||
pub glide: Option<(u16, u16)>,
|
||
}
|
||
|
||
impl WheelZoom {
|
||
/// The state for `place` showing `zoom` percent: fresh for another document or sheet, and
|
||
/// without its glide if the zoom was changed some other way since the glide set it.
|
||
fn at(app: &SheetApp, place: (u64, usize), zoom: u16, now: f64) -> WheelZoom {
|
||
let mut w = app.grid.wheel_zoom.filter(|w| w.place == place).unwrap_or(WheelZoom { place, notches: 0.0, time: now, glide: None });
|
||
if w.glide.is_some_and(|(_, shown)| shown != zoom) {
|
||
w.glide = None;
|
||
}
|
||
w
|
||
}
|
||
}
|
||
|
||
/// Row and column heading labels grow with the zoom as the cells do, as in Excel (at 400% the
|
||
/// labels are four times their 100% size), and never get smaller than a readable 8.5 points.
|
||
pub(crate) fn heading_font_size(zoom: f32) -> f32 {
|
||
(11.5 * (zoom / DISPLAY_SCALE)).max(8.5)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|