gridcraft/crates/ui-egui/src/grid.rs

3094 lines
140 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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, REF_COLORS};
use crate::theme::{self, Tokens};
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;
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Drag {
#[default]
None,
Select,
Rows(u32),
Cols(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,
},
Fill {
target: Option<RangeRef>,
},
/// 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>,
},
Object {
kind: &'static str,
id: u32,
start: Pos2,
orig: (CellRef, f32, f32, f32, f32),
resize: bool,
},
}
#[derive(Default)]
pub struct GridState {
pub drag: Drag,
pub ensure_visible: bool,
pub last_title: Option<String>,
pub drag_select: bool,
pub rect: Option<Rect>,
pub cells_rect: Option<Rect>,
pub filter_menu: Option<(u32, Pos2)>,
pub context_menu: Option<Pos2>,
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 autosave: bool,
pub last_autosave: f64,
pub last_border: String,
pub last_fill: String,
pub last_font_color: String,
pub system_clipboard: Option<String>,
pub trace: Option<serde_json::Value>,
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<((u64, u64, usize), Vec<(RangeRef, u32)>)>,
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>,
/// The grid geometry of the last frame (hit-testing for agents and tests).
pub geo: Option<Geo>,
}
impl GridState {
pub fn autosave(&self) -> bool {
self.autosave
}
pub fn toggle_autosave(&mut self) {
self.autosave = !self.autosave;
}
}
/// Geometry of the grid for one frame (handles zoom and frozen panes).
#[derive(Clone)]
pub struct Geo {
pub rect: Rect,
pub cells: Rect,
pub z: f32,
pub header_w: f32,
pub header_h: f32,
pub fr: u32,
pub fc: u32,
pub frozen_w: f32,
pub frozen_h: f32,
pub scroll: egui::Vec2,
/// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical"
/// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s
/// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by
/// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution).
pub rtl: bool,
/// Prefix sums over custom row heights / column widths, built once per frame.
ri: std::sync::Arc<LineIndex>,
ci: std::sync::Arc<LineIndex>,
/// Page Layout view: cells sit on pages (`scroll` is then in page-layout units).
pub paper: Option<Arc<gridcraft_engine::paper::PageGrid>>,
}
impl Geo {
pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo {
let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0);
let z = zoom * DISPLAY_SCALE;
let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 };
let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS));
let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS));
let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1;
let digits = (last_row as f32).log10().floor() as i32 + 1;
let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 };
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
let frozen_w = (ci.start(fc) as f32) * z;
let frozen_h = (ri.start(fr) as f32) * z;
let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max);
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, rtl: sh.right_to_left, ri, ci, paper: None }
}
/// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale
/// on pages laid out by `grid`; frozen panes don't apply (as in Excel).
pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc<gridcraft_engine::paper::PageGrid>) -> Geo {
let mut g = Geo::new(sh, rect, scroll);
let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0);
g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0);
(g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0);
g.paper = Some(grid);
g
}
/// The point is over cells (in Page Layout view: on a page, inside its margins).
pub fn in_cells(&self, p: Pos2) -> bool {
if !self.cells.contains(p) {
return false;
}
match &self.paper {
Some(pg) => pg.on_cells(
self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64,
self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64,
),
None => true,
}
}
/// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area.
pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 {
match &self.paper {
Some(pg) => pg.row_y(row) as f32,
None => (sh.row_top(row) - sh.row_top(self.fr)) as f32,
}
}
/// Screen x for a logical x (and back): identity unless the sheet is right-to-left.
pub fn mirror_x(&self, x: f32) -> f32 {
if self.rtl { self.rect.left() + self.rect.right() - x } else { x }
}
pub fn mirror_pos(&self, p: Pos2) -> Pos2 {
pos2(self.mirror_x(p.x), p.y)
}
pub fn mirror_rect(&self, r: Rect) -> Rect {
if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r }
}
/// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets).
pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
self.mirror_rect(self.cell_rect(sh, c))
}
pub fn x(&self, _sh: &Sheet, col: u32) -> f32 {
if let Some(pg) = &self.paper {
return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z;
}
if col < self.fc {
self.cells.left() + self.ci.start(col) as f32 * self.z
} else {
self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z
}
}
pub fn y(&self, _sh: &Sheet, row: u32) -> f32 {
if let Some(pg) = &self.paper {
return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z;
}
if row < self.fr {
self.cells.top() + self.ri.start(row) as f32 * self.z
} else {
self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z
}
}
pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
Rect::from_min_max(
pos2(self.x(sh, c.col), self.y(sh, c.row)),
pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z),
)
}
pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect {
let a = self.cell_rect(sh, r.start);
let b = self.cell_rect(sh, r.end);
Rect::from_min_max(a.min, b.max)
}
pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 {
if let Some(pg) = &self.paper {
return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64);
}
let dx = (x - self.cells.left()) / self.z;
if dx < self.frozen_w / self.z {
self.ci.at(dx.max(0.0) as f64)
} else {
self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64)
}
}
pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 {
if let Some(pg) = &self.paper {
return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64);
}
let dy = (y - self.cells.top()) / self.z;
if dy < self.frozen_h / self.z {
self.ri.at(dy.max(0.0) as f64)
} else {
self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64)
}
}
pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef {
CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x))
}
/// Visible scrolled rows (from the first visible row after the frozen ones).
fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) {
let top = self.ri.start(self.fr) + self.scroll.y as f64;
let r0 = self.ri.at(top).max(self.fr);
let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1);
(r0, r1)
}
fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) {
let left = self.ci.start(self.fc) + self.scroll.x as f64;
let c0 = self.ci.at(left).max(self.fc);
let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1);
(c0, c1)
}
/// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`.
/// The panes share one coordinate system, so a range that is not clipped to these is also drawn
/// over the frozen rows/columns when it is scrolled under them.
pub fn range_panes(&self, r: RangeRef) -> Vec<Rect> {
let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h);
let mut xs = Vec::new();
if r.start.col < self.fc {
xs.push((self.cells.left(), split.x));
}
if r.end.col >= self.fc {
xs.push((split.x, self.cells.right()));
}
let mut ys = Vec::new();
if r.start.row < self.fr {
ys.push((self.cells.top(), split.y));
}
if r.end.row >= self.fr {
ys.push((split.y, self.cells.bottom()));
}
ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect()
}
/// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns.
pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool {
let r = self.cell_rect(sh, c);
(self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w)
|| (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h)
}
}
/// Resolved look of a cell for painting.
struct Look {
style: Style,
fill: Option<Color32>,
text_color: Color32,
}
fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 {
c.resolve(&wb.theme).map(theme::color32).unwrap_or(default)
}
fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 {
use gridcraft_engine::numfmt::FormatColor as F;
match c {
F::Black => Color32::BLACK,
F::Blue => Color32::from_rgb(0, 0, 255),
F::Cyan => Color32::from_rgb(0, 255, 255),
F::Green => Color32::from_rgb(0, 128, 0),
F::Magenta => Color32::from_rgb(255, 0, 255),
F::Red => Color32::from_rgb(255, 0, 0),
F::White => Color32::WHITE,
F::Yellow => Color32::from_rgb(255, 255, 0),
F::Indexed(_) => Color32::BLACK,
}
}
fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option<CfLook>) {
let mut style = wb.styles.get(sh.style_id(c)).clone();
if let Some(t) = sh.table_at(c) {
style = gridcraft_engine::tables::table_cell_style(wb, si, t, c);
}
let cfl = cf.look(wb, si, c);
if let Some(l) = &cfl
&& let Some(st) = &l.style
{
if st.fill.pattern != PatternType::None {
style.fill = st.fill;
}
if st.font.color != Color::Auto {
style.font.color = st.font.color;
}
if st.font.bold {
style.font.bold = true;
}
if st.font.italic {
style.font.italic = true;
}
if st.font.underline != Underline::None {
style.font.underline = st.font.underline;
}
if st.font.strike {
style.font.strike = true;
}
for (dst, src) in [
(&mut style.borders.left, st.borders.left),
(&mut style.borders.right, st.borders.right),
(&mut style.borders.top, st.borders.top),
(&mut style.borders.bottom, st.borders.bottom),
] {
if !src.is_none() {
*dst = src;
}
}
}
let mut fill = match style.fill.pattern {
PatternType::None => None,
PatternType::Solid => style.fill.fg.resolve(&wb.theme).map(theme::color32),
_ => {
// Patterns: approximate with a blend of foreground and background.
let fg = style.fill.fg.resolve(&wb.theme).unwrap_or([0, 0, 0]);
let bg = style.fill.bg.resolve(&wb.theme).unwrap_or([255, 255, 255]);
Some(Color32::from_rgb(
((fg[0] as u16 + bg[0] as u16 * 3) / 4) as u8,
((fg[1] as u16 + bg[1] as u16 * 3) / 4) as u8,
((fg[2] as u16 + bg[2] as u16 * 3) / 4) as u8,
))
}
};
if let Some(l) = &cfl
&& let Some(rgb) = l.scale_fill
{
fill = Some(theme::color32(rgb));
}
let text_color = col32(style.font.color, wb, Color32::BLACK);
(Look { style, fill, text_color }, cfl)
}
/// Text for a value with General narrowing to the available width.
fn display_text(
wb: &Workbook,
sh: &Sheet,
c: CellRef,
v: &Value,
st: &Style,
width_px: f32,
char_w: f32,
) -> (String, Option<Color32>, bool, Option<(char, usize)>) {
if sh.show_formulas
&& let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref())
{
return (format!("={}", f.text), None, false, None);
}
if !sh.show_zeros && v.as_f64() == Some(0.0) {
return (String::new(), None, false, None);
}
let code = st.num_fmt.as_str();
// A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General.
if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) {
return (String::new(), None, false, None);
}
if code == "General"
&& let Value::Number(n) = v
{
let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize;
return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) {
Some(t) => (t, None, true, None),
None => ("#".repeat(max_chars.max(1)), None, true, None),
};
}
let f = gridcraft_engine::display::format(v, code, wb);
(f.text, f.color.map(numfmt_color), f.numeric, f.fill)
}
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let available = ui.available_rect_before_wrap();
// A right-to-left sheet has its vertical scrollbar on the left.
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
let bar = 14.0_f32.min(available.width());
let rect = if rtl {
Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max)
} else {
Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom()))
};
let resp = ui.allocate_rect(rect, Sense::click_and_drag());
app.grid.rect = Some(rect);
let Some(d) = app.session.active() else { return };
let wb: Arc<Workbook> = d.wb.clone();
let si = wb.active_sheet;
let Some(sh) = wb.sheet(si) else { return };
let sel = d.selection.clone();
let place = (d.uid, si);
let mut view = app.view();
// Page Layout view: pages on a desk, rulers above and left of the headings.
let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout;
let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 };
let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max);
let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo {
if paper {
let probe = Geo::new(sh, grid_rect, scroll);
if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) {
return Geo::paged(sh, grid_rect, scroll, g);
}
}
Geo::new(sh, rect, scroll)
};
// Scrolling.
let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time));
if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) {
if zoom_delta != 1.0 {
let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round();
let _ = app.session.run("view.zoom", json!({"percent": z}));
} else if scroll_delta != egui::Vec2::ZERO {
let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE };
let mut d = scroll_delta;
// A horizontal swipe moves the content the way the fingers go, which is towards
// higher columns on a mirrored sheet.
if sh.right_to_left {
d.x = -d.x;
}
if modifiers.shift && d.x == 0.0 {
d = vec2(d.y, 0.0);
}
view.scroll -= d / z;
}
}
view.scroll = view.scroll.max(egui::Vec2::ZERO);
let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32);
if paper {
// Page layout units run past sheet units (margins and gaps); the end is known once the
// pages reach the last row / column.
let g = make_geo(app, view.scroll);
if let Some(pg) = &g.paper {
let (ex, ey) = pg.extent();
max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 };
max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 };
}
}
view.scroll.x = view.scroll.x.min(max_x);
view.scroll.y = view.scroll.y.min(max_y);
let mut geo = make_geo(app, view.scroll);
// Keep the active cell visible after keyboard moves.
if app.grid.ensure_visible {
app.grid.ensure_visible = false;
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(sel.active);
if paper {
crate::paper_view::ensure_covers(app, &wb, si, target);
geo = make_geo(app, view.scroll);
}
let cr = geo.cell_rect(sh, target);
let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max);
if target.row >= geo.fr {
if cr.top() < area.top() {
view.scroll.y -= (area.top() - cr.top()) / geo.z;
} else if cr.bottom() > area.bottom() - 2.0 {
view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z;
}
}
if target.col >= geo.fc {
if cr.left() < area.left() {
view.scroll.x -= (area.left() - cr.left()) / geo.z;
} else if cr.right() > area.right() - 2.0 {
view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left());
}
}
view.scroll = view.scroll.max(egui::Vec2::ZERO);
geo = make_geo(app, view.scroll);
}
// Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads).
if let Some(v) = app.view_mut() {
*v = view;
}
app.grid.cells_rect = Some(geo.mirror_rect(geo.cells));
// The selection outline glides to a new selection. It stays on its cells when Animations is
// off, when egui's animations are (the headless harness), and while a screenshot is on its way.
let outline = app.grid.glide.frame(crate::selection_glide::Frame {
place,
range: sel.current(),
target: geo.range_rect(sh, sel.current()),
view: geo.cells,
now,
frame_nr: ui.ctx().cumulative_frame_nr(),
animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(),
});
if app.grid.glide.is_gliding() {
ui.ctx().request_repaint();
}
app.grid.geo = Some(geo.clone());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, t.grid_bg);
// Everything up to the headers is painted in logical (left-to-right) coordinates.
let mirror_from = painter.add(egui::Shape::Noop);
let mut cf = CfCache::new();
let quads = if geo.paper.is_some() {
crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t)
} else {
paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t)
};
// Objects above cells.
let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel);
if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview {
paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh);
}
if geo.paper.is_some() {
crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh);
}
// Headers.
if sh.show_headings {
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t);
}
if geo.rtl {
let mirror_to = painter.add(egui::Shape::Noop);
mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects);
}
if geo.paper.is_some() {
crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t);
}
// Interaction.
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb);
interact(app, ui, &resp, &geo, sh, &wb);
crate::text_box::show(app, ui, &geo, sh);
if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {
in_cell_editor(app, ui, &geo, sh, &wb);
}
filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu);
list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
context_menu(app, ui);
let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) };
vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t);
if app.session.clipboard.is_some() {
// By the clock, not per frame: a gliding outline repaints more often than the ants step.
app.grid.marching_phase = crate::selection_glide::ants_phase(now);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
}
}
/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip.
#[allow(clippy::too_many_arguments)]
fn paint_panes(
app: &SheetApp,
painter: &Painter,
geo: &Geo,
wb: &Workbook,
si: usize,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
outline: Rect,
cf: &mut CfCache,
t: &Tokens,
) -> Vec<((u32, u32), (u32, u32), Rect)> {
// Quadrants: (rows, cols, clip).
let (sr0, sr1) = geo.scroll_rows(sh);
let (sc0, sc1) = geo.scroll_cols(sh);
let fr_rows = (0, geo.fr.saturating_sub(1));
let fr_cols = (0, geo.fc.saturating_sub(1));
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
let mut quads = vec![((sr0, sr1), (sc0, sc1), Rect::from_min_max(split, geo.cells.max))];
if geo.fr > 0 {
quads.push((fr_rows, (sc0, sc1), Rect::from_min_max(pos2(split.x, geo.cells.top()), pos2(geo.cells.right(), split.y))));
}
if geo.fc > 0 {
quads.push(((sr0, sr1), fr_cols, Rect::from_min_max(pos2(geo.cells.left(), split.y), pos2(split.x, geo.cells.bottom()))));
}
if geo.fr > 0 && geo.fc > 0 {
quads.push((fr_rows, fr_cols, Rect::from_min_max(geo.cells.min, split)));
}
for (rows, cols, clip) in &quads {
if rows.0 > rows.1 || cols.0 > cols.1 {
continue;
}
let p = painter.with_clip_rect(*clip);
paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t);
paint_selection(&p, geo, sh, sel, outline, t, app, *rows, *cols);
}
// Freeze lines.
if geo.fr > 0 {
painter.line_segment([pos2(geo.cells.left(), split.y), pos2(geo.cells.right(), split.y)], Stroke::new(1.0, Color32::from_gray(150)));
}
if geo.fc > 0 {
painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150)));
}
quads
}
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {
let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom()));
if track.height() <= 0.0 || track.width() <= 0.0 {
return;
}
let visible = track.height() / geo.z;
let scroll = app.view().scroll.y;
let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1);
let content = geo.scroll_top_of(sh, last);
let extent = (content - visible).max(scroll).max(0.0).min(limit);
let id = ui.id().with("vscroll");
let resp = ui.interact(track, id, Sense::click_and_drag());
let drag_id = id.with("extent");
if resp.drag_started() {
ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));
}
let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height());
let travel = track.height() - thumb_h;
let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };
let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h));
ui.painter().rect_filled(track, 0.0, t.tab_bar);
ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });
let mut next = scroll;
if resp.dragged() && travel > 0.0 {
next += resp.drag_delta().y / travel * extent;
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
if p.y < thumb.top() {
next -= visible;
} else if p.y > thumb.bottom() {
next += visible;
}
}
if resp.hovered() {
next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z;
}
next = next.clamp(0.0, limit);
if next != scroll {
if let Some(v) = app.view_mut() {
v.scroll.y = next;
}
ui.ctx().request_repaint();
}
}
pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
let pad_v = 1.5 * z;
let y_top = rect.top() + pad_v;
let y_bottom = (rect.bottom() - gh - pad_v).max(y_top);
match valign {
VAlign::Top => y_top,
VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0,
VAlign::Bottom => y_bottom,
}
}
pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
let top_inset = 1.0 * z;
let y_top = rect.top() - top_inset;
let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top);
match valign {
VAlign::Top => y_top,
VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0,
VAlign::Bottom => y_bottom,
}
}
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction
/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index
/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture
/// isn't flipped.
fn mirror_painted(ctx: &egui::Context, layer: egui::LayerId, range: std::ops::Range<usize>, geo: &Geo, objects: &[crate::chartview::PaintedObject]) {
let axis = geo.rect.left() + geo.rect.right();
ctx.graphics_mut(|g| {
let list = g.entry(layer);
for i in range {
let object = objects.iter().find(|o| o.shapes.contains(&i));
list.mutate_shape(egui::layers::ShapeIdx(i), |cs| {
cs.clip_rect = geo.mirror_rect(cs.clip_rect);
match object {
Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)),
None => mirror_shape(&mut cs.shape, axis),
}
});
}
});
}
/// Reflect one shape across the vertical line `x = axis / 2`.
fn mirror_shape(shape: &mut egui::Shape, axis: f32) {
use egui::Shape;
let flip = |p: &mut Pos2| p.x = axis - p.x;
match shape {
Shape::Noop | Shape::Callback(_) => {}
Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)),
Shape::Circle(c) => flip(&mut c.center),
Shape::Ellipse(e) => flip(&mut e.center),
Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip),
Shape::Path(p) => {
p.points.iter_mut().for_each(flip);
// Keep the preferred (clockwise) winding for filled polygons.
p.points.reverse();
}
Shape::Rect(r) => {
r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom()));
let c = r.corner_radius;
r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw };
}
Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip),
Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip),
_ => {
let b = shape.visual_bounding_rect();
if b.is_finite() {
shape.translate(vec2(axis - b.right() - b.left(), 0.0));
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_quadrant(
p: &Painter,
geo: &Geo,
wb: &Workbook,
si: usize,
sh: &Sheet,
rows: (u32, u32),
cols: (u32, u32),
cf: &mut CfCache,
t: &Tokens,
) {
let z = geo.z;
let (r0, r1) = rows;
let (c0, c1) = cols;
let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1));
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&range)).copied().collect();
let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied();
// Gridlines.
if sh.show_gridlines {
let stroke = Stroke::new(1.0, t.gridline);
let top = geo.y(sh, r0);
let bottom = geo.y(sh, r1 + 1);
let left = geo.x(sh, c0);
let right = geo.x(sh, c1 + 1);
for c in c0..=c1 + 1 {
if c <= c1 && sh.col_width(c) == 0.0 {
continue;
}
let x = geo.x(sh, c).round() - 0.5;
p.line_segment([pos2(x, top), pos2(x, bottom)], stroke);
}
for r in r0..=r1 + 1 {
if r <= r1 && sh.row_height(r) == 0.0 {
continue;
}
let y = geo.y(sh, r).round() - 0.5;
p.line_segment([pos2(left, y), pos2(right, y)], stroke);
}
}
// Merged areas: blank them (no inner gridlines) and paint as one cell.
let mut cells: Vec<CellRef> = Vec::new();
for r in r0..=r1 {
if sh.row_height(r) == 0.0 {
continue;
}
for c in c0..=c1 {
if sh.col_width(c) == 0.0 {
continue;
}
cells.push(CellRef::new(r, c));
}
}
let empty_ok = |c: CellRef| -> bool { sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none() };
// Backgrounds.
let mut looks: Vec<(CellRef, Rect, Look, Option<CfLook>)> = Vec::with_capacity(cells.len().min(20_000));
for &c in &cells {
let (rect, anchor) = match in_merge(c) {
Some(m) if m.start != c && !(c.row == r0.max(m.start.row) && c.col == c0.max(m.start.col)) => continue,
Some(m) => (geo.range_rect(sh, m), m.start),
None => (geo.cell_rect(sh, c), c),
};
let (lk, cfl) = look(wb, si, sh, anchor, cf);
if let Some(f) = lk.fill {
p.rect_filled(rect.expand(0.0), 0.0, f);
} else if in_merge(c).is_some() {
p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE);
}
looks.push((anchor, rect, lk, cfl));
}
// Text and per-cell decorations.
for (c, rect, lk, cfl) in &looks {
let c = *c;
if let Some(picture) = sh.cell_pictures.get(&c) {
crate::cell_pictures::paint(p, *rect, picture);
continue;
}
let v = sh.value(c);
if let Some(l) = cfl
&& let Some((frac, rgb, gradient)) = l.bar
{
let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0));
let col = theme::color32(rgb);
if gradient {
let mut mesh = egui::Mesh::default();
let light = Color32::from_rgb(
((rgb[0] as u16 + 255 * 3) / 4) as u8,
((rgb[1] as u16 + 255 * 3) / 4) as u8,
((rgb[2] as u16 + 255 * 3) / 4) as u8,
);
let i = mesh.vertices.len() as u32;
for (pt, cc) in [(bar.left_top(), col), (bar.right_top(), light), (bar.right_bottom(), light), (bar.left_bottom(), col)] {
mesh.colored_vertex(pt, cc);
}
mesh.add_triangle(i, i + 1, i + 2);
mesh.add_triangle(i, i + 2, i + 3);
p.add(mesh);
p.rect_stroke(bar, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
} else {
p.rect_filled(bar, 0.0, col);
}
}
let mut text_left = rect.left();
if let Some(l) = cfl
&& let Some((set, idx)) = &l.icon
{
let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z));
paint_cf_icon(p, r, set, *idx);
text_left += 15.0 * z;
}
if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) {
let s = (11.0 * z * 1.2).clamp(8.0, 24.0);
let b = Rect::from_center_size(rect.center(), vec2(s, s));
let on = v == Value::Bool(true);
p.rect_filled(b, 2.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE });
p.rect_stroke(
b,
2.0,
Stroke::new(1.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::from_gray(110) }),
StrokeKind::Inside,
);
if on {
crate::icons::paint(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE);
}
continue;
}
if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() {
continue;
}
let st = &lk.style;
let size = st.font.size * 96.0 / 72.0 * z;
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
let font = FontId::new(size, fam);
// Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns).
let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0);
let avail_w = rect.right() - text_left;
let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w);
if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) {
text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si);
}
if text.is_empty() {
continue;
}
let color = ncolor.unwrap_or(lk.text_color);
let halign = match st.align.h {
HAlign::General => match v {
Value::Number(_) => HAlign::Right,
Value::Bool(_) | Value::Error(_) => HAlign::Center,
_ if numeric => HAlign::Right,
_ => HAlign::Left,
},
h => h,
};
let indent = st.align.indent as f32 * 9.0 * z;
let pad = 2.5 * z;
let rotated = st.align.rotation != 0 && st.align.rotation != 255;
// Rotated text wraps along its own direction (below), not across the column width.
if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) {
let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0);
let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w);
job.halign = match halign {
HAlign::Center | HAlign::CenterAcross => egui::Align::Center,
HAlign::Right => egui::Align::RIGHT,
_ => egui::Align::LEFT,
};
let galley = p.layout_job(job);
let x = match halign {
HAlign::Center | HAlign::CenterAcross => rect.center().x,
HAlign::Right => rect.right() - pad,
_ => text_left + pad + indent,
};
let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z);
p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color);
continue;
}
let first_line = text.lines().next().unwrap_or("").to_string();
// Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the
// merged area): it neither spills, nor is clipped, nor turns into ####.
let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none())
.then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent))
.flatten();
let shrunk = small.is_some();
let font = small.unwrap_or(font);
let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color);
let mut w = galley.size().x;
// Numbers and dates never overflow: they show #### when too narrow.
if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill {
let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize;
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
w = galley.size().x;
}
// Repeat fill (`*` in number formats) / Fill alignment.
if let Some((ch, pos)) = fill_char
&& pos <= first_line.len()
{
let base_w = w;
let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0);
let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize;
let mut s = first_line.clone();
if first_line.is_char_boundary(pos) {
s.insert_str(pos, &ch.to_string().repeat(n));
}
galley = p.layout_no_wrap(s, font.clone(), color);
w = galley.size().x;
}
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
let mut clip = *rect;
if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() {
let mut right = rect.right();
let mut left = rect.left();
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
let mut cc = c.col + 1;
while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 {
right = geo.x(sh, cc + 1);
cc += 1;
}
}
if matches!(halign, HAlign::Right | HAlign::Center) {
let mut cc = c.col;
while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 {
cc -= 1;
left = geo.x(sh, cc);
}
}
clip = Rect::from_min_max(pos2(left, rect.top()), pos2(right, rect.bottom()));
// Paint over the gridlines the text passes through.
if lk.fill.is_none() {
let inner = Rect::from_min_max(pos2(clip.left() + 1.0, clip.top() + 0.5), pos2(clip.right() - 1.0, clip.bottom() - 1.0));
let extra_l = Rect::from_min_max(inner.min, pos2(rect.left() + 1.0, inner.bottom()));
let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max);
if extra_l.width() > 0.0 {
p.rect_filled(extra_l, 0.0, t.grid_bg);
}
if extra_r.width() > 0.0 {
p.rect_filled(extra_r, 0.0, t.grid_bg);
}
}
}
let x = match halign {
HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - w / 2.0,
HAlign::Right => rect.right() - pad - w - indent,
_ => text_left + pad + indent,
};
let gh = galley.size().y;
let y = cell_text_y(st.align.v, *rect, gh, z);
let cp = p.with_clip_rect(clip.intersect(p.clip_rect()));
if rotated {
let angle = -(st.align.rotation as f32).to_radians();
// Upright (±90°) wrapped text wraps to the row height: lines then stack across the column.
let galley = if st.align.wrap && st.align.rotation.abs() == 90 {
let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0));
p.layout_job(job)
} else {
galley.clone()
};
// Upward text grows up and right from the bottom-left corner; downward text grows down
// and left from its anchor, so that anchor sits one block-height in from the top-left.
let pos = if angle > 0.0 {
pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad)
} else {
pos2(rect.left() + pad, rect.bottom() - pad)
};
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
shape.angle = angle;
cp.add(shape);
} else {
cp.galley(pos2(x, y), galley.clone(), color);
}
let baseline = y + gh * 0.82;
match st.font.underline {
Underline::None => {}
Underline::Double | Underline::DoubleAccounting => {
cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color));
cp.line_segment([pos2(x, baseline + 3.0), pos2(x + w, baseline + 3.0)], Stroke::new(1.0, color));
}
_ => {
cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color));
}
}
if st.font.strike {
cp.line_segment([pos2(x, y + gh * 0.55), pos2(x + w, y + gh * 0.55)], Stroke::new(1.0, color));
}
}
// Borders (over fills and text).
for (c, rect, lk, _) in &looks {
let b = &lk.style.borders;
let merge = merges.iter().find(|m| m.start == *c);
let edges = match merge {
// A merged area's outline lives on the cells along each of its edges (Excel writes the
// right edge on the last column's cells and the bottom edge on the last row's), so each
// edge is drawn cell by cell from the cell it belongs to.
Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf),
None => vec![
(b.top, [rect.left_top(), rect.right_top()]),
(b.bottom, [rect.left_bottom(), rect.right_bottom()]),
(b.left, [rect.left_top(), rect.left_bottom()]),
(b.right, [rect.right_top(), rect.right_bottom()]),
],
};
for (line, pts) in edges {
if line.is_none() {
continue;
}
let col = col32(line.color, wb, Color32::BLACK);
paint_border(p, pts, line.style, col);
}
if !b.diag_down.is_none() {
paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK));
}
if !b.diag_up.is_none() {
paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK));
}
// Comment / note indicator.
if let Some(cm) = sh.comments.get(c) {
let tri_col = if cm.threaded { Color32::from_rgb(0x88, 0x55, 0xC8) } else { Color32::from_rgb(0xD0, 0x2E, 0x2E) };
let s = 6.0 * z.max(0.7);
p.add(egui::Shape::convex_polygon(
vec![rect.right_top(), pos2(rect.right(), rect.top() + s), pos2(rect.right() - s, rect.top())],
tri_col,
Stroke::NONE,
));
}
}
// Sparklines.
for sp in &sh.sparklines {
if !range.contains(sp.cell) {
continue;
}
let rect = geo.cell_rect(sh, sp.cell).shrink(3.0);
let vals: Vec<Option<f64>> = match gridcraft_engine::calc::evaluate(wb, si, sp.cell, &sp.source) {
Value::Array(a) => a.data.iter().map(Value::as_f64).collect(),
v => vec![v.as_f64()],
};
let col = sp.color.resolve(&wb.theme).unwrap_or([0x2B, 0x7C, 0xD3]);
let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [col[0], col[1], col[2], 255], sp.markers, rect.width(), rect.height());
crate::chartview::paint_prims(p, rect.min, &prims, 1.0);
}
// Filter buttons.
let mut buttons: Vec<(CellRef, bool)> = Vec::new();
if let Some(af) = &sh.autofilter {
for col in af.range.start.col..=af.range.end.col {
let active = af.criteria.iter().any(|(o, _)| af.range.start.col + o == col);
buttons.push((CellRef::new(af.range.start.row, col), active));
}
}
for tb in &sh.tables {
if tb.filter_button && tb.header_row {
for col in tb.range.start.col..=tb.range.end.col {
buttons.push((CellRef::new(tb.range.start.row, col), false));
}
}
}
for (c, active) in buttons {
if !range.contains(c) {
continue;
}
let r = filter_button_rect(geo, sh, c);
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));
}
}
pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect {
let rect = geo.cell_rect(sh, c);
let s = (rect.height() - 4.0).clamp(10.0, 18.0);
Rect::from_min_size(pos2(rect.right() - s - 2.0, rect.bottom() - s - 2.0), vec2(s, s))
}
/// 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
/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`).
#[allow(clippy::too_many_arguments)]
fn merged_edges(
wb: &Workbook,
si: usize,
sh: &Sheet,
geo: &Geo,
m: RangeRef,
rect: Rect,
rows: (u32, u32),
cols: (u32, u32),
cf: &mut CfCache,
) -> Vec<(BorderLine, [Pos2; 2])> {
let mut out = Vec::new();
let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1));
for col in cs..=ce {
if sh.col_width(col) == 0.0 {
continue;
}
let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1)));
let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top;
let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom;
out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())]));
out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())]));
}
let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1));
for row in rs..=re {
if sh.row_height(row) == 0.0 {
continue;
}
let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1)));
let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left;
let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right;
out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)]));
out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)]));
}
out
}
/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None`
/// when it already fits at its own size.
fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option<FontId> {
let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x;
let room = room.max(1.0);
if natural.is_nan() || natural <= room {
return None;
}
// Glyph advances don't scale exactly linearly (rounding), so leave a little slack.
let size = (font.size * room / natural * 0.97).max(1.0);
Some(FontId::new(size, font.family.clone()))
}
pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
let w = style.width();
let (a, b) = (pts[0], pts[1]);
match style {
BorderStyle::Double => {
let horiz = (a.y - b.y).abs() < 0.5;
let off = if horiz { vec2(0.0, 1.5) } else { vec2(1.5, 0.0) };
p.line_segment([a - off, b - off], Stroke::new(1.0, col));
p.line_segment([a + off, b + off], Stroke::new(1.0, col));
}
BorderStyle::Dashed
| BorderStyle::MediumDashed
| BorderStyle::DashDot
| BorderStyle::MediumDashDot
| BorderStyle::DashDotDot
| BorderStyle::MediumDashDotDot
| BorderStyle::SlantDashDot => {
p.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(w, col), 4.0, 2.0));
}
BorderStyle::Dotted | BorderStyle::Hair => {
p.extend(egui::Shape::dotted_line(&[a, b], col, 2.0, w * 0.5));
}
_ => {
p.line_segment([a, b], Stroke::new(w, col));
}
}
}
fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) {
let n = gridcraft_engine::cf::icon_count(set);
let palette3 = [Color32::from_rgb(0xD1, 0x3B, 0x2F), Color32::from_rgb(0xF2, 0xB8, 0x2E), Color32::from_rgb(0x2E, 0x9E, 0x4F)];
let col = if n <= 3 {
palette3.get(idx).copied().unwrap_or(Color32::GRAY)
} else {
let t = idx as f32 / (n - 1).max(1) as f32;
Color32::from_rgb(
(0xD1 as f32 * (1.0 - t) + 0x2E as f32 * t) as u8,
(0x3B as f32 * (1.0 - t) + 0x9E as f32 * t) as u8,
(0x2F as f32 * (1.0 - t) + 0x4F as f32 * t) as u8,
)
};
let lower = set.to_ascii_lowercase();
if lower.contains("arrow") {
let c = r.center();
let s = r.width() / 2.0;
let pts = if idx + 1 == n {
vec![pos2(c.x, c.y - s), pos2(c.x + s, c.y + 0.1 * s), pos2(c.x - s, c.y + 0.1 * s)]
} else if idx == 0 {
vec![pos2(c.x, c.y + s), pos2(c.x - s, c.y - 0.1 * s), pos2(c.x + s, c.y - 0.1 * s)]
} else {
vec![pos2(c.x + s, c.y), pos2(c.x - 0.1 * s, c.y - s), pos2(c.x - 0.1 * s, c.y + s)]
};
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
} else if lower.contains("rating") || lower.contains("quarter") {
p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, Color32::GRAY));
let frac = idx as f32 / (n - 1).max(1) as f32;
p.rect_filled(
Rect::from_min_size(pos2(r.left() + 2.0, r.bottom() - 2.0 - (r.height() - 4.0) * frac), vec2(r.width() - 4.0, (r.height() - 4.0) * frac)),
1.0,
Color32::from_rgb(0x2B, 0x7C, 0xD3),
);
} else {
p.circle_filled(r.center(), r.width() / 2.0 - 0.5, col);
}
}
/// Where the active cell is on screen: the whole merged cell when it is part of one.
fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect {
match sh.merge_at(sel.active) {
Some(m) => geo.range_rect(sh, m),
None => geo.cell_rect(sh, sel.active),
}
}
/// The rectangles shaded for a selection: every area except the active cell, which stays clear so
/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the
/// current area is drawn this frame: it glides to a new selection, and its fill with it.
pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec<Rect> {
selection_fill_where(geo, sh, sel, outline, |_| true)
}
/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows).
fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec<Rect> {
if sel.is_single_cell() {
return vec![];
}
let active = active_rect(geo, sh, sel);
let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) };
sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect()
}
/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The
/// selection fill is translucent, so the active cell can't be cleared by painting over it; the
/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite,
/// are dropped.
fn rect_around(area: Rect, hole: Rect) -> impl Iterator<Item = Rect> {
// `intersect` takes the sane side of a NaN, which would make a broken hole cover the area.
let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING };
let parts = if hole.is_positive() {
[
Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)),
Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max),
Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)),
Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)),
]
} else {
[area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING]
};
parts.into_iter().filter(|r| r.is_positive() && r.is_finite())
}
/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive).
/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the
/// scrolling pane would otherwise be drawn over the frozen pane as well.
fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool {
r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0
}
/// `outline` is where the current area's border is drawn this frame (see `selection_glide`).
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_selection(
p: &Painter,
geo: &Geo,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
outline: Rect,
t: &Tokens,
app: &SheetApp,
rows: (u32, u32),
cols: (u32, u32),
) {
// Fill all areas except the active cell.
for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) {
p.rect_filled(rect, 0.0, t.sel_fill);
}
let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active));
let active = active_rect(geo, sh, sel);
let active_here = range_in_pane(active_range, rows, cols);
let cur_here = range_in_pane(sel.current(), rows, cols);
// Editing a formula: outline its references instead of the selection.
if let Some(ed) = &app.editor
&& ed.is_formula()
{
for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) {
if sheet.as_deref().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
continue;
}
if !range_in_pane(r, rows, cols) {
continue;
}
let rr = geo.range_rect(sh, r);
let col = REF_COLORS[idx];
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside);
for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] {
p.rect_filled(Rect::from_center_size(corner, vec2(4.0, 4.0)), 0.0, col);
}
}
}
if cur_here {
p.rect_stroke(outline.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle);
}
if active_here && sel.ranges.len() > 1 {
p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside);
}
// Fill handle (bottom-right of the current area).
if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) {
let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0));
p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE);
p.rect_filled(h, 0.0, t.sel_border);
}
// Fill-handle drag preview.
if let crate::grid::Drag::Fill { target: Some(tr) } = &app.grid.drag
&& range_in_pane(*tr, rows, cols)
{
let rr = geo.range_rect(sh, *tr);
p.extend(egui::Shape::dashed_line(
&[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()],
Stroke::new(1.5, Color32::from_gray(90)),
4.0,
3.0,
));
}
if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag {
let r = sel.current();
let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1));
if range_in_pane(moved, rows, cols) {
let rr = geo.range_rect(sh, moved);
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
}
}
if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag {
let x = geo.x(sh, *before);
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent));
}
// Marching ants around the copied range.
if let Some(clip) = &app.session.clipboard
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet)
&& range_in_pane(clip.range, rows, cols)
{
// A multi-area copy marks each area as selected.
let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() };
for area in areas {
let rr = geo.range_rect(sh, *area).expand(0.5);
let phase = app.grid.marching_phase;
let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()];
p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase));
}
}
}
pub(crate) fn paint_headers(
p: &Painter,
geo: &Geo,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
t: &Tokens,
quads: &[((u32, u32), (u32, u32), Rect)],
) {
let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0));
let r = geo.rect;
let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top()));
let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom()));
p.rect_filled(top, 0.0, t.header_bg);
p.rect_filled(left, 0.0, t.header_bg);
let bounds = sel.bounds();
let col_sel = |c: u32| sel.ranges.iter().any(|x| c >= x.start.col && c <= x.end.col);
let row_sel = |rr: u32| sel.ranges.iter().any(|x| rr >= x.start.row && rr <= x.end.row);
let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col);
let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row);
let _ = bounds;
let mut done_cols = std::collections::HashSet::new();
let mut done_rows = std::collections::HashSet::new();
for (rows, cols, clip) in quads {
let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom())));
if cols.0 <= cols.1 {
for c in cols.0..=cols.1 {
if !done_cols.insert((c, clip.left() as i32)) {
continue;
}
let w = sh.col_width(c) * geo.z;
if w <= 0.0 {
continue;
}
let x = geo.x(sh, c);
let cr = Rect::from_min_size(pos2(x, top.top()), vec2(w, top.height()));
if col_sel(c) {
pc.rect_filled(cr, 0.0, if full_col(c) { t.header_all_bg } else { t.header_sel_bg });
pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent));
}
pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line));
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);
}
}
let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom())));
if rows.0 <= rows.1 {
for rr in rows.0..=rows.1 {
if !done_rows.insert((rr, clip.top() as i32)) {
continue;
}
let h = sh.row_height(rr) * geo.z;
if h <= 0.0 {
continue;
}
let y = geo.y(sh, rr);
let cr = Rect::from_min_size(pos2(left.left(), y), vec2(left.width(), h));
if row_sel(rr) {
pr.rect_filled(cr, 0.0, if full_row(rr) { t.header_all_bg } else { t.header_sel_bg });
pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent));
}
pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line));
let color = if row_sel(rr) { t.header_sel_text } else { t.header_text };
pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color);
}
}
}
// Corner (select all) and edges.
let corner = Rect::from_min_max(r.min, geo.cells.min);
p.rect_filled(corner, 0.0, t.header_bg);
let tri = vec![
pos2(corner.right() - 3.0, corner.top() + 5.0),
pos2(corner.right() - 3.0, corner.bottom() - 3.0),
pos2(corner.left() + 7.0, corner.bottom() - 3.0),
];
p.add(egui::Shape::convex_polygon(tri, Color32::from_gray(190), Stroke::NONE));
p.line_segment([pos2(r.left(), geo.cells.top() - 0.5), pos2(r.right(), geo.cells.top() - 0.5)], Stroke::new(1.0, t.header_line));
p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line));
}
// ------------------------------------------------------------------ interaction
/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it.
const OBJECT_RESIZE_RADIUS: f32 = 10.0;
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Text selection and editing must not also move the object or edit worksheet cells.
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
return;
}
let ctx = ui.ctx().clone();
// Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates.
let primary_clicked = resp.clicked_by(egui::PointerButton::Primary);
let pointer = ctx.input(|i| i.pointer.clone());
let mods = ctx.input(|i| i.modifiers);
// Hit-testing works in logical coordinates (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
let in_cells = pos.is_some_and(|p| geo.in_cells(p));
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && geo.rect.contains(p));
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
// The fill handle and the move border only react in the panes where they are painted, not
// over the frozen rows/columns that a scrolled selection lies under.
let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end));
let border_panes = geo.range_panes(sel.current());
let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p));
let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p));
let (freeze_cols, freeze_rows) = freeze_handles(geo);
// Hover cursors.
if resp.hovered()
&& let Some(p) = pos
&& (freeze_cols.contains(p) || freeze_rows.contains(p) || matches!(app.grid.drag, Drag::Freeze { .. }))
{
let cols = match app.grid.drag {
Drag::Freeze { cols, .. } => cols,
_ => freeze_cols.contains(p),
};
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
if app.grid.drag == Drag::None && !pointer.any_down() {
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
resp.clone().on_hover_text_at_pointer(crate::i18n::current(&ctx).tr(tip));
}
} else if resp.hovered()
&& let Some(p) = pos
{
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some();
let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id));
let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS);
let near_object_corner = app.grid.drag == Drag::None && near_corner;
if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) {
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
} else if near_col_edge {
ctx.set_cursor_icon(CursorIcon::ResizeColumn);
} else if near_row_edge {
ctx.set_cursor_icon(CursorIcon::ResizeRow);
} else if on_handle(p) && app.editor.is_none() {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if app.grid.drag == Drag::None && !pointer.any_down() {
resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range.");
}
} else if in_cells && app.editor.is_none() && on_cur_border(p) {
ctx.set_cursor_icon(CursorIcon::Move);
} else if in_cells {
ctx.set_cursor_icon(CursorIcon::Cell);
} else if in_col_header {
let col = geo.col_at(sh, p.x);
let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col));
ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default });
}
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
}
// Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer.
if !app.session.draw_tool.is_empty() && in_cells {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if app.session.draw_tool == "pen" {
if let Some(p) = pos
&& resp.dragged()
{
// Through the cell under the pointer, so frozen panes and pages map right.
let c = geo.cell_at(sh, p);
let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z;
let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z;
app.grid.ink.push([sx, sy]);
}
if !app.grid.ink.is_empty() {
// Live preview.
let pts: Vec<Pos2> = app
.grid
.ink
.iter()
.map(|q| {
let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64));
geo.mirror_pos(pos2(
geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z,
geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z,
))
})
.collect();
ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
}
if resp.drag_stopped() && app.grid.ink.len() >= 2 {
let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink);
let _ = app.run("draw.stroke", json!({"points": pts}));
}
return;
}
if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) {
if let Some(p) = pos
&& let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p)
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink)
{
let _ = app.run("object.delete", json!({"kind": "shape", "id": id}));
}
return;
}
}
// A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear
// button resets it. Runs before the generic object select/drag so tiles stay clickable.
if primary_clicked
&& !resp.drag_started()
&& let Some(p) = pos
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p)
{
app.selected_chart = Some(id);
match act {
crate::chartview::SlicerAction::Toggle(value) => {
let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl}));
}
crate::chartview::SlicerAction::Clear => {
let _ = app.run("slicer.clear", json!({"slicer": id}));
}
}
return;
}
// Objects (charts, pictures, shapes, slicers) take clicks first.
if resp.drag_started() || primary_clicked {
// A drag is recognised only after the pointer has moved, so grab the object where the press began.
let at = if resp.drag_started() { pointer.press_origin().or(pos) } else { pos };
if let Some(p) = at
&& let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_chart = Some(id);
if kind == "shape"
&& resp.double_clicked()
&& sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox)
{
app.begin_text_box_edit(id);
return;
}
if kind == "chart" && resp.double_clicked() {
app.grid.pane = Some("formatChart".into());
}
if resp.drag_started() {
let a = object_anchor(sh, kind, id);
let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS;
if let Some(a) = a {
app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize };
}
}
return;
} else if primary_clicked || resp.drag_started() {
app.selected_chart = None;
}
}
// A double-click is also a click: handle header AutoFit before resize setup returns.
if resp.double_clicked()
&& let Some(p) = pos
{
let autofit = if in_col_header {
col_edge(geo, sh, p.x).map(|(col, _)| {
let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col);
("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) })
})
} else if in_row_header {
row_edge(geo, sh, p.y).map(|(row, _)| {
let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row);
("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) })
})
} else {
None
};
if let Some((command, params)) = autofit {
app.grid.drag = Drag::None;
app.grid.drag_select = false;
app.run_or_alert(command, params);
resp.request_focus();
return;
}
}
// Press: decide the drag mode.
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
// Where the press began (a drag is only recognised after the pointer has moved).
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
// Freeze handles sit on the header edges (and the corner's edges when nothing is frozen).
if freeze_cols.contains(p) || freeze_rows.contains(p) {
app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None };
return;
}
if in_corner {
let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()}));
return;
}
if in_col_header {
if let Some((c, x)) = col_edge(geo, sh, p.x) {
app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z };
return;
}
let c = geo.col_at(sh, p.x);
if resp.drag_started()
&& !mods.any()
&& sel.ranges.len() == 1
&& sel.current().is_full_cols()
&& sel.current().contains(CellRef::new(0, c))
{
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
return;
}
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c });
return;
}
if in_row_header {
if let Some((r, y)) = row_edge(geo, sh, p.y) {
app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z };
return;
}
let r = geo.row_at(sh, p.y);
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0)));
app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r });
return;
}
if !in_cells {
return;
}
let c = geo.cell_at(sh, p);
// Data validation list arrow next to the active cell.
if let Some(r) = validation_arrow(sh, geo, sel.active)
&& r.contains(p)
{
app.grid.list_picker = Some(sel.active);
return;
}
// Filter dropdown buttons.
if is_filter_button(sh, geo, p) {
app.grid.filter_menu = Some((c.col, geo.mirror_pos(p)));
return;
}
// Point mode while editing a formula: clicking inserts a reference.
if let Some(ed) = app.editor.as_mut()
&& ed.can_point()
{
let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone());
let r = match sheet_name {
Some(n) => format!("{}!{}", gridcraft_engine::formula::quote_sheet(&n), c.a1()),
None => c.a1(),
};
ed.insert_ref(&r);
ed.request_focus = true;
ed.point_cell = Some(c);
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
select_to_pointer(app, geo, sh, wb, p);
}
return;
}
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
return;
}
if on_handle(p) {
app.grid.drag = Drag::Fill { target: None };
return;
}
if on_cur_border(p) && resp.drag_started() {
app.grid.drag = Drag::Move { grab: c, to: None };
return;
}
// Double-click edits.
let now = crate::now_ms();
if let Some((last, at)) = app.grid.last_click
&& last == c
&& now - at < 450.0
&& !mods.shift
{
app.grid.last_click = None;
// Following a hyperlink requires a single click in Excel; double-click edits.
app.begin_edit(None, false);
return;
}
app.grid.last_click = Some((c, now));
if mods.shift {
let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = sel.anchor;
}
} else if mods.command {
select_ranges(app, RangeRef::cell(c), true, c);
} else {
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
}
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked
&& !mods.any()
{
let r = geo.cell_rect(sh, c);
if p.x < r.left() + r.width() * 0.8 {
follow_link(app, &h.target);
}
}
}
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
// The first recognised drag already includes movement away from the press.
select_to_pointer(app, geo, sh, wb, p);
}
return;
}
// Dragging.
if resp.dragged()
&& let Some(p) = pos
{
// Auto-scroll near edges.
let edge = 24.0;
let mut auto = egui::Vec2::ZERO;
if p.y > geo.cells.bottom() - edge {
auto.y = 20.0;
}
if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) {
auto.y = -20.0;
}
if p.x > geo.cells.right() - edge {
auto.x = 30.0;
}
if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) {
auto.x = -30.0;
}
let pc = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
);
match app.grid.drag.clone() {
Drag::Select => {
select_to_pointer(app, geo, sh, wb, p);
scroll_by(app, auto);
}
Drag::Rows(anchor) => {
let r = geo.row_at(sh, p.y);
let _ =
app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(0.0, auto.y));
}
Drag::Cols(anchor) => {
let c = geo.col_at(sh, p.x);
let _ =
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::MoveCols { source, .. } => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1);
let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before);
app.grid.drag = Drag::MoveCols { source, before };
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::ResizeCol { col, start, orig } => {
let w = (orig + (p.x - start) / geo.z).max(0.0);
app.toast =
Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms()));
// Inside a selection of whole columns, every selected column takes the new width
// (Ctrl-selected areas included); otherwise only the dragged one.
let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) {
sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::<Vec<_>>().join(",")
} else {
RangeRef::cols(col, col).a1()
};
let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()}));
coalesce_last_undo(app);
}
Drag::ResizeRow { row, start, orig } => {
let h = (orig + (p.y - start) / geo.z).max(0.0);
app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms()));
let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) {
sel.ranges
.iter()
.filter(|r| r.is_full_rows())
.map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1))
.collect::<Vec<_>>()
.join(",")
} else {
format!("{}:{}", row + 1, row + 1)
};
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
coalesce_last_undo(app);
}
Drag::Freeze { cols, .. } => {
app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) };
}
Drag::Fill { .. } => {
let src = sel.current();
let target = fill_target(src, pc);
app.grid.drag = Drag::Fill { target: Some(target) };
scroll_by(app, auto);
}
Drag::Move { grab, .. } => {
let src = sel.current();
let dr = pc.row as i64 - grab.row as i64;
let dc = pc.col as i64 - grab.col as i64;
app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) };
scroll_by(app, auto);
}
Drag::Object { kind, id, start, orig, resize } => {
let d = (p - start) / geo.z;
let (cell, dx, dy, w, h) = orig;
let params = if resize {
json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)})
} else {
// Re-anchor at the cell under the new top-left.
let x0 = sh.col_left(cell.col) as f32 + dx + d.x;
let y0 = sh.row_top(cell.row) as f32 + dy + d.y;
let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64));
json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32})
};
let _ = app.session.run("object.move", params);
coalesce_last_undo(app);
}
Drag::None => {}
}
}
// Release.
if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) {
match std::mem::take(&mut app.grid.drag) {
Drag::Select if resp.drag_stopped() => {
if let Some(p) = pos {
select_to_pointer(app, geo, sh, wb, p);
}
}
Drag::Fill { target: Some(t) } => {
let src = sel.current();
if t != src {
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
let mode = if mods.command { "alternate" } else { "series" };
if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
}
Drag::Freeze { cols, to: Some(to) } => {
let (rows_now, cols_now) = (geo.fr, geo.fc);
let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) };
if (rows, cols) != (rows_now, cols_now) {
// A1 (nothing left frozen) unfreezes.
app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()}));
}
}
Drag::Move { to: Some(to), .. } => {
let src = sel.current();
if to != src.start {
let _ = app.session.run("edit.cut", json!({"range": src.a1()}));
let _ = app.run("edit.paste", json!({"at": to.a1()}));
}
}
Drag::MoveCols { source, before: Some(before) } => {
if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
_ => {}
}
app.grid.drag_select = false;
}
// Double-click the fill handle.
if resp.double_clicked()
&& let Some(p) = pos
&& on_handle(p)
{
// Fill down as far as the neighbouring column goes.
let src = sel.current();
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
let mut last = src.end.row;
while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000
{
last += 1;
}
if last > src.end.row {
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
}
}
// Right-click: context menu.
if resp.secondary_clicked()
&& let Some(p) = pos
{
if in_cells && !sel.contains(geo.cell_at(sh, p)) {
let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()}));
}
if in_col_header || in_row_header {
let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) };
let inside = sel.ranges.iter().any(|r| {
if rows {
r.is_full_rows() && idx >= r.start.row && idx <= r.end.row
} else {
r.is_full_cols() && idx >= r.start.col && idx <= r.end.col
}
});
if !inside {
let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) };
let _ = app.session.run("selection.set", json!({"range": range.a1()}));
}
app.grid.header_menu = Some((geo.mirror_pos(p), rows));
} else {
app.grid.context_menu = Some(geo.mirror_pos(p));
}
}
if primary_clicked || resp.drag_started() {
resp.request_focus();
}
keyboard(app, &ctx, resp, geo, sh);
}
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
let cell = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
);
if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_cell {
let range = RangeRef::new(start, cell);
let text = if range.is_single() { range.start.a1() } else { range.a1() };
let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text };
ed.insert_ref(&text);
ed.point_cell = Some(start);
}
} else if let Some(d) = app.session.active() {
// Read the current anchor: starting a drag may have just replaced the selection.
let anchor = d.selection.anchor;
let active = d.selection.active;
let mut ranges: Vec<String> = d.selection.ranges.iter().map(|r| r.a1()).collect();
ranges.pop();
ranges.push(RangeRef::new(anchor, cell).a1());
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = anchor;
}
}
}
fn coalesce_last_undo(app: &mut SheetApp) {
// Merge consecutive drag steps into one undo entry.
if let Some(d) = app.session.active_mut()
&& d.undo.len() >= 2
{
let n = d.undo.len();
if d.undo[n - 1].label == d.undo[n - 2].label {
d.undo.remove(n - 1);
}
}
}
fn scroll_by(app: &mut SheetApp, d: egui::Vec2) {
if d != egui::Vec2::ZERO
&& let Some(v) = app.view_mut()
{
v.scroll = (v.scroll + d).max(egui::Vec2::ZERO);
}
}
fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef {
let below = pc.row as i64 - src.end.row as i64;
let above = src.start.row as i64 - pc.row as i64;
let right = pc.col as i64 - src.end.col as i64;
let left = src.start.col as i64 - pc.col as i64;
let vert = below.max(above);
let horiz = right.max(left);
if vert <= 0 && horiz <= 0 {
return src;
}
if vert >= horiz {
if below > 0 {
RangeRef::new(src.start, CellRef::new(pc.row, src.end.col))
} else {
RangeRef::new(CellRef::new(pc.row, src.start.col), src.end)
}
} else if right > 0 {
RangeRef::new(src.start, CellRef::new(src.end.row, pc.col))
} else {
RangeRef::new(CellRef::new(src.start.row, pc.col), src.end)
}
}
fn on_border(r: Rect, p: Pos2) -> bool {
let outer = r.expand(3.0);
let inner = r.shrink(3.0);
outer.contains(p) && !inner.contains(p)
}
/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip
/// on the frozen columns' right edge, and one in the left part of the row-header strip on the
/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the
/// header edge keeps resizing the column or row.
pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) {
let x = geo.cells.left() + geo.frozen_w;
let y = geo.cells.top() + geo.frozen_h;
let cols = Rect::from_min_max(pos2(x - 3.0, geo.rect.top()), pos2(x + 3.0, geo.rect.top() + geo.header_h * 0.4));
let rows = Rect::from_min_max(pos2(geo.rect.left(), y - 3.0), pos2(geo.rect.left() + geo.header_w * 0.4, y + 3.0));
(cols, rows)
}
/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest
/// the pointer, limited so the frozen pane leaves room for the scrolling one.
fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
if cols {
if p.x <= geo.cells.left() {
return 0;
}
let c = geo.col_at(sh, p.x);
let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1)));
let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c };
let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_COLS - 1)
} else {
if p.y <= geo.cells.top() {
return 0;
}
let rr = geo.row_at(sh, p.y);
let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1)));
let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr };
let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_ROWS - 1)
}
}
/// Paints the freeze handles and, while one is dragged, where the freeze line will go.
fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) {
let (cols, rows) = freeze_handles(geo);
let bar = Color32::from_gray(150);
p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar);
p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar);
if let Drag::Freeze { cols, to: Some(to) } = *drag {
let stroke = Stroke::new(2.0, t.accent);
if cols {
let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z;
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.cells.bottom())], stroke);
} else {
let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z;
p.line_segment([pos2(geo.rect.left(), y), pos2(geo.cells.right(), y)], stroke);
}
}
}
fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> {
let c = geo.col_at(sh, x);
for cc in [c, c.saturating_sub(1)] {
let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z;
if (x - edge).abs() <= 4.0 {
return Some((cc, edge));
}
}
None
}
fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> {
let r = geo.row_at(sh, y);
for rr in [r, r.saturating_sub(1)] {
let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z;
if (y - edge).abs() <= 3.0 {
return Some((rr, edge));
}
}
None
}
fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool {
let c = geo.cell_at(sh, p);
let header = sh.autofilter.as_ref().is_some_and(|af| af.range.start.row == c.row && c.col >= af.range.start.col && c.col <= af.range.end.col)
|| sh
.tables
.iter()
.any(|t| t.filter_button && t.header_row && t.range.start.row == c.row && c.col >= t.range.start.col && c.col <= t.range.end.col);
header && filter_button_rect(geo, sh, c).contains(p)
}
fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option<gridcraft_engine::model::Anchor> {
match kind {
"chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor),
"image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor),
"slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor),
_ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor),
}
}
fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
let Some(id) = app.selected_chart else { return false };
let kind = if sh.charts.iter().any(|object| object.id == id) {
"chart"
} else if sh.images.iter().any(|object| object.id == id) {
"image"
} else if sh.shapes.iter().any(|object| object.id == id) {
"shape"
} else {
return false;
};
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
app.selected_chart = None;
true
}
pub fn follow_link(app: &mut SheetApp, target: &str) {
if target.contains("://") || target.starts_with("mailto:") {
if let Some(f) = &app.services.open_url {
f(target);
}
} else {
let t = target.trim_start_matches('#');
let _ = app.session.run("edit.goTo", json!({"reference": t}));
app.grid.ensure_visible = true;
}
}
/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already
/// selected (unless it is the only one, so the selection never ends up empty).
fn toggled_ranges(mut ranges: Vec<String>, clicked: String, add: bool) -> Vec<String> {
if !add {
return vec![clicked];
}
if ranges.len() > 1 && ranges.contains(&clicked) {
ranges.retain(|x| *x != clicked);
} else if !ranges.contains(&clicked) {
ranges.push(clicked);
}
ranges
}
fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) {
let current: Vec<String> =
if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] };
let clicked = r.a1();
let ranges = toggled_ranges(current, clicked.clone(), add);
// A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area.
let params =
if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) };
let _ = app.session.run("selection.set", params);
}
/// Grid keyboard handling when no text field has focus.
fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) {
// Focus left on an editor that no longer exists goes back to the grid.
let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()];
let editing = app.editor.is_some() || app.text_box_editor.is_some();
if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) {
resp.request_focus();
}
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
if editing || app.dialog.is_some() || other_focus || app.message.is_some() {
return;
}
if ctx.memory(|m| m.focused()).is_none() {
resp.request_focus();
}
// The grid owns arrows, Tab and Escape (egui would otherwise move focus between widgets).
ctx.memory_mut(|m| {
m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
});
let events = ctx.input(|i| i.events.clone());
// A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.
let shortcut_pressed = events.iter().any(|e| {
matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. }
if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space)))
});
let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64;
for ev in events {
match ev {
egui::Event::Copy => {
app.copy_to_clipboard(ctx, "edit.copy");
}
egui::Event::Cut => {
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.paste_external(text),
egui::Event::Text(_) if shortcut_pressed => {}
egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {
app.begin_edit(Some(text), false);
if let Some(ed) = app.editor.as_mut() {
ed.caret = ed.text.chars().count();
}
return;
}
egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => {
use egui::Key;
if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) {
continue;
}
let cmd = m.command;
let shift = m.shift;
let mv = |app: &mut SheetApp, dr: i64, dc: i64| {
let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd}));
app.grid.ensure_visible = true;
};
match key {
Key::ArrowUp => mv(app, -1, 0),
Key::ArrowDown => mv(app, 1, 0),
// Arrows follow the screen: on a right-to-left sheet Left goes to the next column.
Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }),
Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }),
Key::PageDown if !cmd => mv(app, page, 0),
Key::PageUp if !cmd => mv(app, -page, 0),
Key::PageDown => app.run_or_alert("sheet.next", json!({})),
Key::PageUp => app.run_or_alert("sheet.previous", json!({})),
Key::Home if cmd => {
let r = sh.freeze.map(|f| f.0).unwrap_or(0);
let c = sh.freeze.map(|f| f.1).unwrap_or(0);
let _ = app.session.run("selection.set", json!({"cell": CellRef::new(r, c).a1()}));
app.grid.ensure_visible = true;
}
Key::End if cmd => {
let end = sh.used_range().map(|u| u.end).unwrap_or_default();
let _ = app.session.run("selection.set", json!({"cell": end.a1()}));
app.grid.ensure_visible = true;
}
Key::Home => {
let a = app.session.active().map(|d| d.selection.active).unwrap_or_default();
let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()}));
app.grid.ensure_visible = true;
}
Key::Enter => {
if m.alt || m.ctrl {
app.begin_edit(None, false);
} else {
app.move_after_enter(if shift { -1 } else { 1 }, 0);
}
}
Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }),
Key::F2 => app.begin_edit(None, false),
Key::Delete if delete_selected_object(app, sh) => {}
Key::Delete => app.run_or_alert("edit.clearContents", json!({})),
Key::Backspace if delete_selected_object(app, sh) => {}
Key::Backspace => {
// Excel for Mac: Delete clears the cell and starts editing it.
app.run_or_alert("edit.clearContents", json!({}));
if app.session.active().is_some_and(|d| d.selection.is_single_cell()) {
app.begin_edit(Some(String::new()), false);
}
}
Key::Escape => {
app.session.clipboard = None;
app.session.format_painter = None;
}
Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})),
Key::F8 if m.alt => {
app.open_dialog("macros", json!({}));
}
Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})),
Key::F4 if !cmd => {}
_ if cmd => crate::ribbon::shortcut(app, key, m),
_ => {}
}
}
_ => {}
}
}
}
fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {
let Some(ed) = app.editor.as_ref() else { return };
if ed.sheet != wb.active_sheet {
return;
}
let cell = ed.cell;
let rect = match sh.merge_at(cell) {
Some(m) => geo.range_rect(sh, m),
None => geo.cell_rect(sh, cell),
};
let st = wb.styles.get(sh.style_id(cell)).clone();
let size = st.font.size * 96.0 / 72.0 * geo.z;
let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
let w = (text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left());
let h = (size * 1.3 * lines + 4.0).max(rect.height());
// Grows to the left on a right-to-left sheet.
let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h)));
let fill = match st.fill.pattern {
PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE),
_ => Color32::WHITE,
};
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
let quadrant = Rect::from_min_max(
pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }),
pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }),
);
let clip = ui.clip_rect().intersect(quadrant);
let painter = ui.painter().with_clip_rect(clip);
painter.rect_filled(erect, 0.0, fill);
painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
let id = egui::Id::new("gridcraft.cell_editor");
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0))));
child.set_clip_rect(clip);
crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0);
}
/// Test the popup itself, not whether the pointer is over any part of the app.
/// A trigger can sit outside its popup, so its opening click must be ignored.
fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool {
response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere())
}
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
let Some((col, at)) = app.grid.filter_menu else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
let si = wb.active_sheet;
let Some(sh) = wb.sheet(si) else { return };
let _ = geo;
// Tables get an autofilter on demand.
let table_range = sh
.tables
.iter()
.find(|t| t.range.start.row <= at.y as u32 || true)
.filter(|t| col >= t.range.start.col && col <= t.range.end.col)
.map(|t| t.range);
if sh.autofilter.is_none()
&& let Some(r) = table_range
{
let _ = app.session.run("data.filter", json!({"range": r.a1()}));
let _ = app.session.run("data.filter", json!({"range": r.a1()}));
if let Some(sh2) = app.session.active().and_then(|d| d.wb.active()).map(|s| s.autofilter.is_some())
&& !sh2
{
let d = app.session.active_mut();
if let Some(d) = d {
let wbm = Arc::make_mut(&mut d.wb);
if let Some(s) = wbm.sheet_mut(si) {
s.autofilter = Some(gridcraft_engine::model::AutoFilter { range: r, criteria: vec![] });
}
}
}
}
let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb);
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
let key = egui::Id::new(("filter_sel", col));
let search_key = egui::Id::new(("filter_search", col));
let mut close = false;
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
let response = area.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(230.0);
if ui.button("↑ Sort Ascending").clicked() {
let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)}));
close = true;
}
if ui.button("↓ Sort Descending").clicked() {
let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)}));
close = true;
}
ui.separator();
ui.label(egui::RichText::new("Filter").strong());
let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::<Vec<(String, bool)>>(key)).unwrap_or(values.clone());
// A search narrows the list; Select All and Apply then only cover the matches.
let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default();
let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY));
if !was_open {
field.request_focus();
}
let needle = search.trim().to_lowercase();
let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle);
let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b);
let mut all_new = all;
let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" };
if ui.checkbox(&mut all_new, label).changed() {
for c in checks.iter_mut().filter(|(v, _)| matches(v)) {
c.1 = all_new;
}
}
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) {
ui.checkbox(on, v.as_str());
}
});
ui.ctx().data_mut(|d| {
d.insert_temp(key, checks.clone());
d.insert_temp(search_key, search.clone());
});
let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect();
ui.separator();
ui.horizontal(|ui| {
if ui.button("Clear Filter").clicked() {
let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true}));
close = true;
}
if ui.button("Apply").clicked() {
let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on);
let vals: Vec<String> = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect();
let p = if checks.iter().all(|(_, b)| *b) {
json!({"column": col_to_letters(col), "clear": true})
} else {
json!({"column": col_to_letters(col), "values": vals, "blanks": blanks})
};
let _ = app.run("data.filterBy", p);
close = true;
}
});
});
});
if close || popup_dismissed(&response.response, was_open) {
ui.ctx().data_mut(|d| {
d.remove::<Vec<(String, bool)>>(key);
d.remove::<String>(search_key);
});
app.grid.filter_menu = None;
}
}
fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
let Some(at) = app.grid.context_menu else { return };
let mut close = false;
let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let insert_copied = insert_copied_item(app);
let mut items: Vec<(&str, &str)> = vec![
("Cut", "edit.cut"),
("Copy", "edit.copy"),
("Paste", "edit.paste"),
("Paste Special…", "ui:pasteSpecial"),
("-", ""),
("Insert…", "ui:insertCells"),
("Delete…", "ui:deleteCells"),
("Clear Contents", "edit.clearContents"),
("-", ""),
("Filter by Selected Cell's Value", "ui:filterValue"),
("Sort A to Z", "data.sortAscending"),
("Sort Z to A", "data.sortDescending"),
("-", ""),
("New Comment", "ui:newComment"),
("New Note", "ui:newNote"),
("-", ""),
("Format Cells…", "ui:formatCells"),
("Pick From Drop-down List…", "ui:pickList"),
("Define Name…", "ui:defineName"),
("Link…", "ui:insertLink"),
];
if let Some(item) = insert_copied {
items.insert(6, item);
}
let lang = crate::i18n::current(ui.ctx());
for (label, id) in items {
if label == "-" {
ui.separator();
continue;
}
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() {
close = true;
match id {
"ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})),
"ui:insertPaste" => app.open_dialog("insertPaste", json!({})),
"ui:insertCells" => app.open_dialog("insertCells", json!({})),
"ui:deleteCells" => app.open_dialog("deleteCells", json!({})),
"ui:formatCells" => app.open_dialog("formatCells", json!({})),
"ui:newComment" => app.open_dialog("comment", json!({"threaded": true})),
"ui:newNote" => app.open_dialog("comment", json!({"threaded": false})),
"ui:defineName" => app.open_dialog("defineName", json!({})),
"ui:insertLink" => app.open_dialog("insertLink", json!({})),
"ui:pickList" => app.open_dialog("pickList", json!({})),
"ui:filterValue" => {
if let Some(d) = app.session.active() {
let a = d.selection.active;
let text = d.wb.active().map(|sh| gridcraft_engine::display::cell_text(&d.wb, sh, a)).unwrap_or_default();
let has = d.wb.active().is_some_and(|sh| sh.autofilter.is_some());
if !has {
let _ = app.run("data.filter", json!({}));
}
app.run_or_alert("data.filterBy", json!({"column": col_to_letters(a.col), "values": [text]}));
}
}
"edit.copy" | "edit.cut" => {
app.copy_to_clipboard(ui.ctx(), id);
}
other => app.run_or_alert(other, json!({})),
}
}
}
});
});
if close
|| ui.input(|i| i.key_pressed(egui::Key::Escape))
|| (ui.input(|i| i.pointer.any_pressed())
&& !resp.response.contains_pointer()
&& !resp.response.hovered()
&& ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p)))
{
app.grid.context_menu = None;
}
}
/// Used by `EditState` users to keep the active cell visible while pointing.
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) {
let Some(d) = app.session.active() else { return };
let from = ed.point_cell.unwrap_or(ed.cell);
let to = from.offset_clamped(dr, dc);
let text = if extend {
let anchor = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(|t| CellRef::parse(t.split(':').next().unwrap_or(""))).unwrap_or(from);
RangeRef::new(anchor, to).a1()
} else {
to.a1()
};
let _ = d;
ed.insert_ref(&text);
ed.point_cell = Some(to);
app.grid.ensure_visible = true;
}
/// The dropdown arrow beside a cell with an in-cell list validation.
pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option<Rect> {
let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?;
if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) {
return None;
}
let r = geo.cell_rect(sh, c);
let s = r.height().clamp(14.0, 22.0);
Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s)))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_overlays(
app: &mut SheetApp,
ui: &egui::Ui,
p: &Painter,
geo: &Geo,
wb: &Workbook,
si: usize,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
) {
// Validation dropdown arrow for the active cell.
if app.editor.is_none()
&& let Some(r) = validation_arrow(sh, geo, sel.active)
{
p.rect_filled(r, 2.0, Color32::from_gray(245));
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside);
crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60));
}
// Input message of the active cell's validation.
if app.editor.is_none()
&& let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active)))
&& dv.show_input
&& !(dv.input_title.is_empty() && dv.input_message.is_empty())
&& !geo.hidden_under_frozen(sh, sel.active)
{
let r = geo.cell_rect(sh, sel.active);
let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
ui.set_max_width(220.0);
if !dv.input_title.is_empty() {
ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
}
ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
});
});
}
// Notes: shown on hover (or always when marked visible).
let hover = app.grid.hover_cell;
let all_notes = app.session.show_all_notes;
for (c, cm) in &sh.comments {
let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c);
if !show || app.grid.drag != Drag::None {
continue;
}
let r = geo.cell_rect(sh, *c);
if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) {
continue;
}
let at = pos2(r.right() + 10.0, r.top());
p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90)));
let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
ui.set_max_width(240.0);
ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK));
ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK));
for (a, t) in &cm.replies {
ui.separator();
ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK));
}
});
},
);
}
// Hyperlink tooltip.
if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c))
&& app.editor.is_none()
{
let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target));
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
ui.label(tip);
});
}
// Trace precedents / dependents arrows.
if let Some(trace) = app.grid.trace.clone()
&& let Some(items) = trace.as_array()
{
let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
let active = geo.cell_rect(sh, sel.active).center();
for it in items {
if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
continue;
}
let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse);
let Some(t) = target else { continue };
let tr = geo.range_rect(sh, t);
if !t.is_single() {
for pane in geo.range_panes(t) {
p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside);
}
}
let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) };
p.line_segment([from, to], Stroke::new(1.5, blue));
p.circle_filled(from, 3.0, blue);
let d = (to - from).normalized();
let n = vec2(-d.y, d.x);
p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE));
}
}
let _ = (wb, si);
}
fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
let Some(c) = app.grid.list_picker else { return };
let Some(d) = app.session.active() else { return };
let wb = d.wb.clone();
let si = wb.active_sheet;
let Some(sh) = wb.sheet(si) else { return };
let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else {
app.grid.list_picker = None;
return;
};
let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv);
let r = geo.cell_rect(sh, c);
let (at, pivot) = (geo.mirror_pos(r.left_bottom()), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
let mut close = false;
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| {
ui.set_min_width(r.width().max(100.0));
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
for it in &items {
if ui.add(egui::Button::new(it.as_str()).frame(false).min_size(vec2(r.width().max(100.0), 18.0))).clicked() {
app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it}));
close = true;
}
}
});
});
});
if close || popup_dismissed(&response.response, was_open) {
app.grid.list_picker = None;
}
}
fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
let Some((at, rows)) = app.grid.header_menu else { return };
let mut close = false;
let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
let insert_copied = insert_copied_item(app);
let mut items: Vec<(&str, &str)> = if rows {
vec![
("Cut", "edit.cut"),
("Copy", "edit.copy"),
("Paste", "edit.paste"),
("-", ""),
("Insert", "home.insertRows"),
("Delete", "home.deleteRows"),
("Clear Contents", "edit.clearContents"),
("-", ""),
("Row Height…", "ui:rowHeight"),
("AutoFit Row Height", "home.autofitRowHeight"),
("Hide", "home.hideRows"),
("Unhide", "home.unhideRows"),
("Group", "data.group"),
]
} else {
vec![
("Cut", "edit.cut"),
("Copy", "edit.copy"),
("Paste", "edit.paste"),
("-", ""),
("Insert", "home.insertColumns"),
("Delete", "home.deleteColumns"),
("Clear Contents", "edit.clearContents"),
("-", ""),
("Column Width…", "ui:columnWidth"),
("AutoFit Column Width", "home.autofitColumnWidth"),
("Hide", "home.hideColumns"),
("Unhide", "home.unhideColumns"),
("Sort A to Z", "data.sortAscending"),
("Group", "data.group"),
]
};
if let Some(item) = insert_copied {
items.insert(5, item);
}
let lang = crate::i18n::current(ui.ctx());
for (label, id) in items {
if label == "-" {
ui.separator();
continue;
}
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
close = true;
match id {
"ui:insertPaste" => app.open_dialog("insertPaste", json!({})),
"ui:rowHeight" => app.open_dialog("rowHeight", json!({})),
"ui:columnWidth" => app.open_dialog("columnWidth", json!({})),
"edit.copy" | "edit.cut" => {
app.copy_to_clipboard(ui.ctx(), id);
}
other => app.run_or_alert(other, json!({})),
}
}
}
});
});
if close || popup_dismissed(&response.response, was_open) {
app.grid.header_menu = None;
}
}
/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a
/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift
/// (Insert Paste). A cut block can only be pasted, so it gets no entry.
fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> {
let clip = app.session.clipboard.as_ref()?;
let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols();
let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid);
match (cut, lines) {
(true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")),
(true, false) => None,
(false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")),
(false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")),
}
}
fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool {
let Some(d) = app.session.active() else { return false };
d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox"
}
fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) {
let Some(d) = app.session.active() else { return };
let key = (d.uid, d.revision, d.wb.active_sheet);
if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) {
let pages: Vec<(RangeRef, u32)> = app
.session
.run("file.printPreview", json!({}))
.ok()
.and_then(|v| {
v["pageList"].as_array().map(|a| {
a.iter()
.filter(|pg| pg["sheet"].as_str() == Some(sh.name.as_str()))
.filter_map(|pg| Some((RangeRef::parse(pg["range"].as_str()?)?, pg["page"].as_u64()? as u32)))
.collect()
})
})
.unwrap_or_default();
app.grid.pages = Some((key, pages));
}
let Some((_, pages)) = &app.grid.pages else { return };
if pages.is_empty() {
return;
}
// Grey everything, then clear the printed pages.
let mut printed = pages[0].0;
for (r, _) in pages {
printed = printed.union(r);
}
let pr = geo.range_rect(sh, printed);
let grey = Color32::from_black_alpha(70);
let c = p.clip_rect();
for r in [
Rect::from_min_max(c.min, pos2(c.right(), pr.top())),
Rect::from_min_max(pos2(c.left(), pr.bottom()), c.max),
Rect::from_min_max(pos2(c.left(), pr.top()), pos2(pr.left(), pr.bottom())),
Rect::from_min_max(pos2(pr.right(), pr.top()), pos2(c.right(), pr.bottom())),
] {
if r.is_positive() {
p.rect_filled(r, 0.0, grey);
}
}
let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8);
for (r, n) in pages {
let rr = geo.range_rect(sh, *r);
if !rr.intersects(c) {
continue;
}
p.extend(egui::Shape::dashed_line(
&[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()],
Stroke::new(2.0, blue),
6.0,
3.0,
));
p.text(
rr.center(),
Align2::CENTER_CENTER,
format!("Page {n}"),
theme::ui_bold((rr.height().min(rr.width()) / 6.0).clamp(14.0, 72.0)),
Color32::from_black_alpha(45),
);
}
p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside);
}
#[cfg(test)]
mod selection_fill_tests {
use super::*;
#[test]
fn rect_around_leaves_the_hole_and_never_overlaps() {
let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0));
let check = |hole: Rect, pieces: usize| {
let parts: Vec<Rect> = rect_around(area, hole).collect();
assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}");
let cut = area.intersect(hole);
let cut_area = if cut.is_positive() { cut.area() } else { 0.0 };
let shaded: f32 = parts.iter().map(Rect::area).sum();
assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}");
for (i, a) in parts.iter().enumerate() {
assert!(area.contains_rect(*a), "nothing outside the area: {a:?}");
assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}");
// The fill is translucent: a part shaded twice would show darker.
assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}");
}
};
let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0));
// In the middle, in each corner (where a drag began), along an edge.
check(cell(200.0, 100.0), 4);
check(cell(100.0, 50.0), 2);
check(cell(336.0, 50.0), 2);
check(cell(100.0, 234.0), 2);
check(cell(336.0, 234.0), 2);
check(cell(100.0, 100.0), 3);
// A merged cell as wide, or as tall, as the area; one that is all of it, or more.
check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2);
check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2);
check(area, 0);
check(area.expand(30.0), 0);
// The active cell is in another area, touches this one, or is only partly inside it.
check(cell(500.0, 300.0), 1);
check(cell(400.0, 50.0), 1);
check(cell(380.0, 240.0), 2);
}
#[test]
fn rect_around_survives_hostile_numbers() {
let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0));
let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0];
for a in bad {
for b in bad {
let weird = Rect::from_min_max(pos2(a, b), pos2(b, a));
for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] {
for part in rect_around(area, hole) {
assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}");
}
}
// A hole that makes no sense leaves the whole area shaded.
if !weird.intersects(sane) {
assert_eq!(rect_around(sane, weird).collect::<Vec<_>>(), vec![sane]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() {
let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0));
let gh = 18.5;
let z = 1.0;
let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z);
let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z);
let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z);
assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})");
assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})");
// Verify tall row (e.g. 40px)
let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0));
let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z);
let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z);
let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z);
assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})");
assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})");
}
#[test]
fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() {
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0));
let gh = 30.0;
let z = 1.0;
let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z);
let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z);
let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z);
assert!(y_top < y_center);
assert!(y_center < y_bottom);
// Overflow case: gh exceeds rect height
let gh_overflow = 60.0;
let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z);
assert!(y_bottom_ovf >= rect.top());
}
}
#[cfg(test)]
mod pane_tests {
use super::*;
fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef {
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
}
#[test]
fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() {
// Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10.
let frozen_cols = ((2, 20), (0, 1));
let frozen_rows = ((0, 1), (2, 10));
let corner = ((0, 1), (0, 1));
let scrolled = ((2, 20), (2, 10));
let sel = rng(5, 4, 7, 6);
assert!(range_in_pane(sel, scrolled.0, scrolled.1));
assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1));
assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1));
assert!(!range_in_pane(sel, corner.0, corner.1));
}
#[test]
fn ctrl_click_on_a_selected_header_deselects_it() {
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"]));
assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"]));
assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"]));
assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"]));
assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"]));
}
#[test]
fn selection_across_the_freeze_line_is_drawn_in_both_panes() {
let sel = rng(5, 0, 7, 6);
assert!(range_in_pane(sel, (2, 20), (0, 1)));
assert!(range_in_pane(sel, (2, 20), (2, 10)));
}
/// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns.
fn frozen_geo(sh: &mut Sheet) -> Geo {
sh.freeze = Some((2, 2));
let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0);
Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll)
}
#[test]
fn overlays_skip_cells_scrolled_under_the_frozen_columns() {
let mut sh = Sheet::new("S");
let geo = frozen_geo(&mut sh);
assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2)));
assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4)));
assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5)));
assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0)));
let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0));
assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2)));
}
#[test]
fn ranges_are_clipped_to_the_panes_that_hold_them() {
let mut sh = Sheet::new("S");
let geo = frozen_geo(&mut sh);
let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);
assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]);
assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2);
assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4);
assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]);
// The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown.
let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom();
assert!(handle.x < split.x);
assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle)));
}
}