Merge pull request #381 from vangel76/fix/shrink-to-fit

Grid: render Shrink to fit (#375)

# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/grid.rs
This commit is contained in:
Brandon Thomas 2026-10-11 15:43:32 -04:00
commit 8498dedb7d
3 changed files with 83 additions and 1 deletions

View File

@ -134,6 +134,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|---|---|
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) |
| 2026-10-11 | Merged cells draw their whole outline: right and bottom edges come from the last column's and last row's cells (#376) |
| 2026-10-11 | Shrink to fit draws too-wide text and numbers in a smaller font inside the cell or merged area (#375) |
| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) |
| 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) |
| 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) |

View File

@ -934,10 +934,17 @@ pub(crate) fn paint_quadrant(
continue;
}
let first_line = text.lines().next().unwrap_or("").to_string();
// Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the
// merged area): it neither spills, nor is clipped, nor turns into ####.
let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none())
.then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent))
.flatten();
let shrunk = small.is_some();
let font = small.unwrap_or(font);
let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color);
let mut w = galley.size().x;
// Numbers and dates never overflow: they show #### when too narrow.
if (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill {
if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill {
let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize;
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
w = galley.size().x;
@ -1161,6 +1168,19 @@ fn merged_edges(
out
}
/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None`
/// when it already fits at its own size.
fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option<FontId> {
let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x;
let room = room.max(1.0);
if natural.is_nan() || natural <= room {
return None;
}
// Glyph advances don't scale exactly linearly (rounding), so leave a little slack.
let size = (font.size * room / natural * 0.97).max(1.0);
Some(FontId::new(size, font.family.clone()))
}
pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) {
let w = style.width();
let (a, b) = (pts[0], pts[1]);

View File

@ -0,0 +1,61 @@
//! Shrink to fit (#375): too-wide content is drawn in a smaller font inside its cell.
use egui::Shape;
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
let shrink = json!({"align": {"shrink": true}});
s.execute("cell.set", json!({"cell": "A1", "input": "Executive Producer"})).unwrap();
s.execute("home.formatCells", json!({"range": "A1", "style": shrink})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "Control Producer"})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "123456789012"})).unwrap();
s.execute("home.formatCells", json!({"range": "A4", "style": {"num_fmt": "#,##0", "align": {"shrink": true}}})).unwrap();
s.execute("cell.set", json!({"cell": "A5", "input": "123456789012"})).unwrap();
s.execute("home.formatCells", json!({"range": "A5", "style": {"num_fmt": "#,##0"}})).unwrap();
s.execute("selection.set", json!({"range": "H20"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
h
}
/// The painted text shape whose text is `text`: (font size, width, left x).
fn text_shape(h: &Harness, text: &str) -> Option<(f32, f32, f32)> {
h.output().shapes.iter().find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == text => {
let size = t.galley.job.sections.first().map(|s| s.format.font_id.size)?;
Some((size, t.galley.size().x, t.pos.x))
}
_ => None,
})
}
#[test]
fn shrink_to_fit_text_and_numbers_fit_their_cell() {
let h = harness();
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let a1 = geo.cell_rect(sheet, CellRef::parse("A1").unwrap());
let (small, w, x) = text_shape(&h, "Executive Producer").expect("A1 is drawn");
let (normal, cw, _) = text_shape(&h, "Control Producer").expect("A2 is drawn");
assert!(small < normal, "A1 is drawn smaller ({small} vs {normal})");
assert!(cw > a1.width(), "the control is wider than the column");
assert!(x >= a1.left() && x + w <= a1.right(), "A1 fits inside column A");
// A number shrinks instead of turning into ####; its unshrunk twin still shows ####.
let (num, nw, _) = text_shape(&h, "123,456,789,012").expect("A4 shows the number");
assert!(num < normal && nw <= a1.width());
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text.starts_with("##"))));
}