mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 04:06:52 -06:00
2219 lines
97 KiB
Rust
2219 lines
97 KiB
Rust
//! The worksheet grid: virtualised painting of only the visible cells, frozen panes,
|
|
//! headers, selection, fill handle, column/row resizing, text overflow, borders, conditional
|
|
//! formats, tables, comments, filters, sparklines, floating charts/pictures/shapes and the
|
|
//! in-cell editor.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use egui::{Align2, Color32, CursorIcon, FontId, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
|
use gridcraft_engine::cf::{CfCache, CfLook};
|
|
use gridcraft_engine::core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
|
use gridcraft_engine::model::{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),
|
|
ResizeCol {
|
|
col: u32,
|
|
start: f32,
|
|
orig: f32,
|
|
},
|
|
ResizeRow {
|
|
row: u32,
|
|
start: f32,
|
|
orig: f32,
|
|
},
|
|
Fill {
|
|
target: Option<RangeRef>,
|
|
},
|
|
/// 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,
|
|
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)>,
|
|
}
|
|
|
|
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,
|
|
/// Prefix sums over custom row heights / column widths, built once per frame.
|
|
ri: std::sync::Arc<LineIndex>,
|
|
ci: std::sync::Arc<LineIndex>,
|
|
}
|
|
|
|
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, ri, ci }
|
|
}
|
|
pub fn x(&self, _sh: &Sheet, col: u32) -> f32 {
|
|
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 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 {
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
if code == "checkbox" {
|
|
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();
|
|
let rect = Rect::from_min_max(available.min, pos2((available.right() - 14.0).max(available.left()), 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 mut view = app.view();
|
|
// Scrolling.
|
|
let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers));
|
|
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 = sh.zoom as f32 / 100.0 * DISPLAY_SCALE;
|
|
let mut d = scroll_delta;
|
|
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 max_y = sh.row_top(MAX_ROWS - 40) as f32;
|
|
let max_x = sh.col_left(MAX_COLS - 20) as f32;
|
|
view.scroll.x = view.scroll.x.min(max_x);
|
|
view.scroll.y = view.scroll.y.min(max_y);
|
|
let mut geo = Geo::new(sh, rect, 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);
|
|
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 = Geo::new(sh, rect, 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.cells);
|
|
let painter = ui.painter_at(rect);
|
|
painter.rect_filled(rect, 0.0, t.grid_bg);
|
|
let mut cf = CfCache::new();
|
|
// 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, &mut cf, &t);
|
|
paint_selection(&p, &geo, sh, &sel, &t, app);
|
|
}
|
|
// 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)));
|
|
}
|
|
// Objects above cells.
|
|
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 app.session.view_mode == "pageBreakPreview" || app.session.view_mode == "pageLayout" {
|
|
paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh);
|
|
}
|
|
// Headers.
|
|
if sh.show_headings {
|
|
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
|
|
}
|
|
// Interaction.
|
|
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
|
|
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);
|
|
vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t);
|
|
if app.session.clipboard.is_some() {
|
|
app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0;
|
|
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
|
|
}
|
|
}
|
|
|
|
/// 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, right: f32, limit: f32, t: &Tokens) {
|
|
let track = Rect::from_min_max(pos2(geo.rect.right(), geo.cells.top() + geo.frozen_h), pos2(right, 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 = (sh.row_top(last) - sh.row_top(geo.fr)) as f32;
|
|
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();
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
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;
|
|
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" {
|
|
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 (text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w);
|
|
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 = match st.align.v {
|
|
VAlign::Top => rect.top() + 1.0,
|
|
VAlign::Center => rect.center().y - galley.size().y / 2.0,
|
|
_ => rect.bottom() - galley.size().y - 1.0,
|
|
};
|
|
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();
|
|
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 (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 = match st.align.v {
|
|
VAlign::Top => rect.top() + 1.0,
|
|
VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - gh / 2.0,
|
|
VAlign::Bottom => rect.bottom() - gh - 0.5 * 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 edges = [
|
|
(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))
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) {
|
|
// Fill all areas except the active cell.
|
|
for r in &sel.ranges {
|
|
if r.is_single() && sel.ranges.len() == 1 {
|
|
continue;
|
|
}
|
|
let rect = geo.range_rect(sh, *r);
|
|
p.rect_filled(rect, 0.0, t.sel_fill);
|
|
}
|
|
let active = geo.cell_rect(sh, sh.merge_at(sel.active).map(|m| m.start).unwrap_or(sel.active));
|
|
let active = match sh.merge_at(sel.active) {
|
|
Some(m) => geo.range_rect(sh, m),
|
|
None => active,
|
|
};
|
|
if !(sel.ranges.len() == 1 && sel.current().is_single()) {
|
|
p.rect_filled(active.shrink(1.0), 0.0, t.grid_bg.gamma_multiply(0.0));
|
|
}
|
|
let cur = geo.range_rect(sh, sel.current());
|
|
// 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;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle);
|
|
if 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() {
|
|
let h = Rect::from_center_size(cur.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 {
|
|
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));
|
|
let rr = geo.range_rect(sh, moved);
|
|
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
|
|
}
|
|
// 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)
|
|
{
|
|
let rr = geo.range_rect(sh, clip.range).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));
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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() {
|
|
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);
|
|
let pos = pointer.interact_pos().or(pointer.hover_pos());
|
|
let in_cells = pos.is_some_and(|p| geo.cells.contains(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));
|
|
|
|
// Hover cursors.
|
|
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();
|
|
if near_col_edge {
|
|
ctx.set_cursor_icon(CursorIcon::ResizeColumn);
|
|
} else if near_row_edge {
|
|
ctx.set_cursor_icon(CursorIcon::ResizeRow);
|
|
} else if handle.contains(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_border(cur_rect, p) {
|
|
ctx.set_cursor_icon(CursorIcon::Move);
|
|
} else if in_cells {
|
|
ctx.set_cursor_icon(CursorIcon::Cell);
|
|
} else if in_col_header {
|
|
ctx.set_cursor_icon(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()
|
|
{
|
|
let sx = sh.col_left(geo.fc) as f32 + geo.scroll.x + (p.x - geo.cells.left() - geo.frozen_w) / geo.z;
|
|
let sy = sh.row_top(geo.fr) as f32 + geo.scroll.y + (p.y - geo.cells.top() - geo.frozen_h) / 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| {
|
|
pos2(
|
|
geo.cells.left() + geo.frozen_w + (q[0] - sh.col_left(geo.fc) as f32 - geo.scroll.x) * geo.z,
|
|
geo.cells.top() + geo.frozen_h + (q[1] - sh.row_top(geo.fr) as f32 - geo.scroll.y) * 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;
|
|
}
|
|
}
|
|
// Objects (charts, pictures, shapes) take clicks first.
|
|
if resp.drag_started() || primary_clicked {
|
|
if let Some(p) = pos
|
|
&& 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() < 10.0;
|
|
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().or(pos) else { 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);
|
|
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, 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 handle.contains(p) {
|
|
app.grid.drag = Drag::Fill { target: None };
|
|
return;
|
|
}
|
|
if on_border(cur_rect, 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 {
|
|
let mut ranges: Vec<String> = sel.ranges.iter().map(|r| r.a1()).collect();
|
|
ranges.push(c.a1());
|
|
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()}));
|
|
} 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::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()));
|
|
let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) {
|
|
sel.current().a1()
|
|
} 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) {
|
|
format!("{}:{}", sel.current().start.row + 1, sel.current().end.row + 1)
|
|
} else {
|
|
format!("{}:{}", row + 1, row + 1)
|
|
};
|
|
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
|
|
coalesce_last_undo(app);
|
|
}
|
|
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 {
|
|
let mode = if mods.command { "copy" } 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::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()}));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
app.grid.drag_select = false;
|
|
}
|
|
|
|
// Double-click the fill handle.
|
|
if resp.double_clicked()
|
|
&& let Some(p) = pos
|
|
&& handle.contains(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((p, rows));
|
|
} else {
|
|
app.grid.context_menu = Some(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)
|
|
}
|
|
|
|
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 + 1);
|
|
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 + 1);
|
|
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),
|
|
_ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor),
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) {
|
|
let mut ranges: Vec<String> =
|
|
if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] };
|
|
ranges.push(r.a1());
|
|
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()}));
|
|
}
|
|
|
|
/// 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());
|
|
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.run_or_alert("edit.paste", json!({"text": text})),
|
|
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, pressed: true, modifiers: m, .. } => {
|
|
use egui::Key;
|
|
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),
|
|
Key::ArrowLeft => mv(app, 0, -1),
|
|
Key::ArrowRight => mv(app, 0, 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 => app.run_or_alert("edit.clearContents", json!({})),
|
|
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::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})),
|
|
Key::Space if shift && !cmd => app.run_or_alert("selection.row", 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());
|
|
let erect = 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,
|
|
};
|
|
ui.painter().rect_filled(erect, 0.0, fill);
|
|
ui.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))));
|
|
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 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());
|
|
let all = checks.iter().all(|(_, b)| *b);
|
|
let mut all_new = all;
|
|
if ui.checkbox(&mut all_new, "(Select All)").changed() {
|
|
for c in checks.iter_mut() {
|
|
c.1 = all_new;
|
|
}
|
|
}
|
|
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
|
for (v, on) in checks.iter_mut() {
|
|
ui.checkbox(on, v.as_str());
|
|
}
|
|
});
|
|
ui.ctx().data_mut(|d| d.insert_temp(key, checks.clone()));
|
|
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));
|
|
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 items: &[(&str, &str)] = &[
|
|
("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"),
|
|
];
|
|
for (label, id) in items {
|
|
if *label == "-" {
|
|
ui.separator();
|
|
continue;
|
|
}
|
|
if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(190.0, 20.0))).clicked() {
|
|
close = true;
|
|
match *id {
|
|
"ui:pasteSpecial" => app.open_dialog("pasteSpecial", 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 {
|
|
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)]
|
|
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())
|
|
{
|
|
let r = geo.cell_rect(sh, sel.active);
|
|
let at = pos2(r.left() + 8.0, r.bottom() + 6.0);
|
|
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).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;
|
|
for (c, cm) in &sh.comments {
|
|
let show = cm.visible || hover == Some(*c);
|
|
if !show || app.grid.drag != Drag::None {
|
|
continue;
|
|
}
|
|
let r = geo.cell_rect(sh, *c);
|
|
if !p.clip_rect().intersects(r) {
|
|
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)));
|
|
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).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() {
|
|
p.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 mut close = false;
|
|
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).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 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"),
|
|
]
|
|
};
|
|
for (label, id) in items {
|
|
if label == "-" {
|
|
ui.separator();
|
|
continue;
|
|
}
|
|
if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
|
|
close = true;
|
|
match id {
|
|
"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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|