mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 20:40:05 -06:00
653 lines
28 KiB
Rust
653 lines
28 KiB
Rust
use super::cell_paint::{
|
|
Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign,
|
|
rotated_text_pos, shrink_font, wrapped_cell_text_y,
|
|
};
|
|
use super::geo::Geo;
|
|
use super::tables::{filter_button_rect, paint_filter_button};
|
|
use super::{
|
|
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, VAlign,
|
|
Value, Workbook, pos2, theme, vec2,
|
|
};
|
|
|
|
/// 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.
|
|
pub(super) 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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restrict top-aligned wrapped text to complete lines inside the cell.
|
|
///
|
|
/// Preserve the cell and viewport clip when every line fits. Otherwise,
|
|
/// exclude overflowing lines while retaining the visible fitting lines.
|
|
fn wrapped_text_clip(p: &Painter, rect: Rect, galley: &egui::Galley, y: f32, valign: VAlign) -> Rect {
|
|
let mut clip = rect.intersect(p.clip_rect());
|
|
if valign != VAlign::Top {
|
|
return clip;
|
|
}
|
|
|
|
let Some(last_row) = galley.rows.last() else {
|
|
return clip;
|
|
};
|
|
|
|
if y + last_row.rect().bottom() <= rect.bottom() + 0.01 {
|
|
return clip;
|
|
}
|
|
|
|
let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01);
|
|
|
|
clip.max.y = last_full_bottom.map(|bottom| clip.bottom().min(bottom)).unwrap_or(clip.top()).max(clip.top());
|
|
|
|
clip
|
|
}
|
|
pub(crate) struct QuadrantPaint<'a> {
|
|
pub(crate) painter: &'a Painter,
|
|
pub(crate) geo: &'a Geo,
|
|
pub(crate) workbook: &'a Workbook,
|
|
pub(crate) sheet_index: usize,
|
|
pub(crate) sheet: &'a Sheet,
|
|
pub(crate) rows: (u32, u32),
|
|
pub(crate) cols: (u32, u32),
|
|
pub(crate) cf: &'a mut CfCache,
|
|
pub(crate) tokens: &'a Tokens,
|
|
}
|
|
|
|
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
|
|
///
|
|
/// For top-aligned wrapped text, painting is limited to complete lines
|
|
/// fitting within the cell height while respecting viewport clipping.
|
|
/// Other vertical alignment modes retain their existing behavior.
|
|
pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
|
|
let (r0, r1) = quadrant.rows;
|
|
let (c0, c1) = quadrant.cols;
|
|
let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1));
|
|
let merges = visible_merges(&quadrant, range);
|
|
paint_gridlines(&quadrant);
|
|
let cells = visible_cells(&quadrant);
|
|
let looks = paint_backgrounds(&mut quadrant, &cells, &merges);
|
|
let outside = outside_text_cells(&mut quadrant, &merges);
|
|
paint_cells(&mut quadrant, &looks, &outside, &merges);
|
|
paint_borders(&mut quadrant, &looks, &merges);
|
|
paint_sparklines(&quadrant, range);
|
|
paint_filter_buttons(&quadrant, range);
|
|
}
|
|
|
|
fn visible_merges(q: &QuadrantPaint<'_>, range: RangeRef) -> Vec<RangeRef> {
|
|
let (r0, r1) = (range.start.row, range.end.row);
|
|
let (c0, c1) = (range.start.col, range.end.col);
|
|
let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1)));
|
|
q.sheet.merges.iter().filter(|m| m.intersects(&near)).copied().collect()
|
|
}
|
|
|
|
fn paint_gridlines(q: &QuadrantPaint<'_>) {
|
|
let p = q.painter;
|
|
let geo = q.geo;
|
|
let sh = q.sheet;
|
|
let t = q.tokens;
|
|
let (r0, r1) = q.rows;
|
|
let (c0, c1) = q.cols;
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn visible_cells(q: &QuadrantPaint<'_>) -> Vec<CellRef> {
|
|
let sh = q.sheet;
|
|
let (r0, r1) = q.rows;
|
|
let (c0, c1) = q.cols;
|
|
// 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));
|
|
}
|
|
}
|
|
cells
|
|
}
|
|
|
|
fn paint_backgrounds(q: &mut QuadrantPaint<'_>, cells: &[CellRef], merges: &[RangeRef]) -> Vec<(CellRef, Rect, Look, Option<CfLook>)> {
|
|
let p = q.painter;
|
|
let geo = q.geo;
|
|
let wb = q.workbook;
|
|
let si = q.sheet_index;
|
|
let sh = q.sheet;
|
|
let cf = &mut *q.cf;
|
|
let (r0, _) = q.rows;
|
|
let c0 = q.cols.0;
|
|
let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied();
|
|
// 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));
|
|
}
|
|
looks
|
|
}
|
|
|
|
fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(CellRef, Rect, Look, Option<CfLook>)> {
|
|
let geo = q.geo;
|
|
let wb = q.workbook;
|
|
let si = q.sheet_index;
|
|
let sh = q.sheet;
|
|
let cf = &mut *q.cf;
|
|
let (r0, r1) = q.rows;
|
|
let (c0, c1) = q.cols;
|
|
let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied();
|
|
let empty_ok = |c: CellRef| sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none();
|
|
// Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty
|
|
// cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane).
|
|
let mut outside: Vec<(CellRef, Rect, Look, Option<CfLook>)> = Vec::new();
|
|
let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc);
|
|
for r in r0..=r1 {
|
|
if sh.row_height(r) == 0.0 {
|
|
continue;
|
|
}
|
|
let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
|
|
let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
|
|
for n in [left, right].into_iter().flatten() {
|
|
if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {
|
|
let (lk, cfl) = look(wb, si, sh, n, cf);
|
|
outside.push((n, geo.cell_rect(sh, n), lk, cfl));
|
|
}
|
|
}
|
|
}
|
|
outside
|
|
}
|
|
|
|
fn paint_cells(
|
|
q: &mut QuadrantPaint<'_>,
|
|
looks: &[(CellRef, Rect, Look, Option<CfLook>)],
|
|
outside: &[(CellRef, Rect, Look, Option<CfLook>)],
|
|
merges: &[RangeRef],
|
|
) {
|
|
for cell in looks.iter().chain(outside) {
|
|
paint_cell(q, cell, merges);
|
|
}
|
|
}
|
|
|
|
fn paint_cell_decorations(q: &QuadrantPaint<'_>, c: CellRef, rect: Rect, lk: &Look, cfl: Option<&CfLook>) -> bool {
|
|
let v = q.sheet.value(c);
|
|
let p = q.painter;
|
|
let z = q.geo.z;
|
|
if let Some(picture) = q.sheet.cell_pictures.get(&c) {
|
|
crate::cell_pictures::paint(p, rect, picture);
|
|
return true;
|
|
}
|
|
if let Some(l) = cfl
|
|
&& let Some((frac, rgb, gradient)) = l.bar
|
|
{
|
|
paint_data_bar(p, rect, frac, rgb, gradient);
|
|
}
|
|
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);
|
|
}
|
|
if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) {
|
|
paint_checkbox(p, rect, z, v == Value::Bool(true));
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn paint_data_bar(p: &egui::Painter, rect: Rect, frac: f32, rgb: [u8; 3], gradient: bool) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
fn paint_checkbox(p: &egui::Painter, rect: Rect, z: f32, on: bool) {
|
|
let s = (11.0 * z * 1.2).clamp(8.0, 24.0);
|
|
let b = Rect::from_center_size(rect.center(), vec2(s, s));
|
|
let color = if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE };
|
|
p.rect_filled(b, 2.0, color);
|
|
p.rect_stroke(b, 2.0, Stroke::new(1.0, if on { color } else { Color32::from_gray(110) }), StrokeKind::Inside);
|
|
if on {
|
|
crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE);
|
|
}
|
|
}
|
|
|
|
fn text_overflows_cell(numeric: bool, value: &Value, rotation: i16, width: f32, rect_width: f32, pad: f32) -> bool {
|
|
!numeric && !matches!(value, Value::Number(_)) && rotation.abs() != 90 && width > rect_width - 2.0 * pad
|
|
}
|
|
|
|
fn should_expand_text(filter_space: f32, overflows: bool, across_end: u32, col: u32, in_merge: bool) -> bool {
|
|
filter_space == 0.0 && (overflows || across_end > col) && !in_merge
|
|
}
|
|
|
|
/// Paint one worksheet cell using its computed appearance and visible geometry.
|
|
///
|
|
/// Handles text layout, wrapping, alignment, overflow into neighbouring cells,
|
|
/// and clipping, while preserving cell decoration and merged-cell constraints.
|
|
fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLook>), merges: &[RangeRef]) {
|
|
let (cell, rect, lk, cfl) = painted;
|
|
let c = *cell;
|
|
let p = q.painter;
|
|
let geo = q.geo;
|
|
let wb = q.workbook;
|
|
let si = q.sheet_index;
|
|
let sh = q.sheet;
|
|
let t = q.tokens;
|
|
let z = geo.z;
|
|
let (c0, c1) = q.cols;
|
|
let in_merge = |cell| merges.iter().find(|m| m.contains(cell)).copied();
|
|
let empty_ok = |cell| sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
|
|
if paint_cell_decorations(q, c, *rect, lk, cfl.as_ref()) {
|
|
return;
|
|
}
|
|
let v = sh.value(c);
|
|
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 cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() {
|
|
return;
|
|
}
|
|
// Align and clip header text inside the content area, away from the dropdown.
|
|
let filter_space = sh.filter_button_space(c) * z;
|
|
let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom()));
|
|
let rect = &text_rect;
|
|
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() {
|
|
return;
|
|
}
|
|
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 => painted_halign(h, geo.rtl),
|
|
};
|
|
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);
|
|
let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v);
|
|
p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color);
|
|
return;
|
|
}
|
|
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
|
|
let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect();
|
|
// The fill position indexes `text`; move it back by the breaks removed before it.
|
|
let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count()))));
|
|
// 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.
|
|
let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill;
|
|
if hashed {
|
|
// Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell.
|
|
let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0);
|
|
let n = ((avail_w - 2.0 * pad) / hash_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. A number already shown as #### keeps
|
|
// it: rebuilding it from the text would overflow and clip its leading digits.
|
|
if let Some((ch, pos)) = fill_char
|
|
&& !hashed
|
|
&& 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;
|
|
}
|
|
// Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it.
|
|
let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() {
|
|
center_across_end(c.col, c1 + 20, |col| {
|
|
let n = CellRef::new(c.row, col);
|
|
empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross
|
|
})
|
|
} else {
|
|
c.col
|
|
};
|
|
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
|
|
let mut clip = *rect;
|
|
let overflows = text_overflows_cell(numeric, &v, st.align.rotation, w, rect.width(), pad);
|
|
if should_expand_text(filter_space, overflows, across_end, c.col, in_merge(c).is_some()) {
|
|
let mut right = rect.right();
|
|
let mut left = rect.left();
|
|
if across_end > c.col {
|
|
right = geo.x(sh, across_end + 1);
|
|
}
|
|
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::CenterAcross => clip.center().x - w / 2.0,
|
|
HAlign::Center | 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()
|
|
};
|
|
let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent);
|
|
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);
|
|
}
|
|
paint_text_decorations(&cp, TextDecoration { x, y, width: w, height: gh, color, underline: st.font.underline, strike: st.font.strike });
|
|
}
|
|
|
|
struct TextDecoration {
|
|
x: f32,
|
|
y: f32,
|
|
width: f32,
|
|
height: f32,
|
|
color: Color32,
|
|
underline: Underline,
|
|
strike: bool,
|
|
}
|
|
|
|
fn paint_text_decorations(p: &Painter, decoration: TextDecoration) {
|
|
let TextDecoration { x, y, width, height, color, underline, strike } = decoration;
|
|
let baseline = y + height * 0.82;
|
|
match underline {
|
|
Underline::None => {}
|
|
Underline::Double | Underline::DoubleAccounting => {
|
|
p.line_segment([pos2(x, baseline + 1.0), pos2(x + width, baseline + 1.0)], Stroke::new(1.0, color));
|
|
p.line_segment([pos2(x, baseline + 3.0), pos2(x + width, baseline + 3.0)], Stroke::new(1.0, color));
|
|
}
|
|
_ => {
|
|
p.line_segment([pos2(x, baseline + 1.0), pos2(x + width, baseline + 1.0)], Stroke::new(1.0, color));
|
|
}
|
|
}
|
|
if strike {
|
|
p.line_segment([pos2(x, y + height * 0.55), pos2(x + width, y + height * 0.55)], Stroke::new(1.0, color));
|
|
}
|
|
}
|
|
|
|
fn paint_borders(q: &mut QuadrantPaint<'_>, looks: &[(CellRef, Rect, Look, Option<CfLook>)], merges: &[RangeRef]) {
|
|
let p = q.painter;
|
|
let geo = q.geo;
|
|
let wb = q.workbook;
|
|
let si = q.sheet_index;
|
|
let sh = q.sheet;
|
|
let cf = &mut *q.cf;
|
|
let t = q.tokens;
|
|
let z = geo.z;
|
|
let (r0, r1) = q.rows;
|
|
let (c0, c1) = q.cols;
|
|
// 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, MergedArea { m: *m, rect: *rect, rows: (r0, r1), cols: (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,
|
|
));
|
|
}
|
|
// Background error checking: a number stored as text gets Excel's green corner (#1994).
|
|
if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() {
|
|
let s = 6.0 * z.max(0.7);
|
|
p.add(egui::Shape::convex_polygon(
|
|
vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)],
|
|
t.error_check_green,
|
|
Stroke::NONE,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn paint_sparklines(q: &QuadrantPaint<'_>, range: RangeRef) {
|
|
let p = q.painter;
|
|
let geo = q.geo;
|
|
let wb = q.workbook;
|
|
let si = q.sheet_index;
|
|
let sh = q.sheet;
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
fn paint_filter_buttons(q: &QuadrantPaint<'_>, range: RangeRef) {
|
|
let p = q.painter;
|
|
let geo = q.geo;
|
|
let sh = q.sheet;
|
|
// 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;
|
|
}
|
|
paint_filter_button(p, filter_button_rect(geo, sh, c), active);
|
|
}
|
|
}
|