From 2fe3bfc0779d5441de35fc4accebd5f1b61473c1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:59:09 -0600 Subject: [PATCH 1/2] #1786: the live pen stroke is drawn as the ink it commits While dragging with a pen, the stroke under the pointer was always painted blue at 2 pt, whatever pen or highlighter was chosen, so the preview and the committed ink disagreed (a highlighter previewed as a thin blue line). The preview now uses the colour and width draw.stroke stores (pen blue and 2 pt by default, 0.5-40 pt, whole points) and the translucency Ink shapes are painted with above 8 pt. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid/interact.rs | 36 +++++++++++++++++++++++++---- docs/ui-parity.md | 3 ++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..a94f08e6d 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -225,10 +225,10 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { )) }) .collect(); - input - .ui - .painter() - .add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); + // The line under the pointer is the ink the stroke will commit (#1786). + let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); + let (color, width) = pen_ink(&app.session.draw_color, app.session.draw_width, &theme); + input.ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(width * geo.z, color))); } if resp.drag_stopped() && app.grid.ink.len() >= 2 { let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); @@ -249,6 +249,17 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { false } +/// The colour and width (points) of the ink a pen stroke commits, as `draw.stroke` stores it (the +/// pen's colour, pen blue by default; its width, 2 pt by default, kept to 0.5–40 pt and stored +/// whole) and as Ink shapes paint it (a highlighter, over 8 pt, is translucent). +pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model::Theme) -> (Color32, f32) { + let pen_blue = gridcraft_engine::model::Color::rgb(0x1F, 0x5F, 0xC9); + let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue); + let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0); + let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9)); + (if width > 8.0 { color.gamma_multiply(0.45) } else { color }, width) +} + fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position); let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header); @@ -1044,3 +1055,20 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ed.point_anchor = Some((sheet, anchor)); app.grid.ensure_visible = true; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pen_preview_is_the_ink_the_stroke_commits() { + // #1786: the preview was always blue at 2 pt, whatever the pen. + let theme = gridcraft_engine::model::Theme::default(); + assert_eq!(pen_ink("#C00000", 3.0, &theme), (Color32::from_rgb(0xC0, 0, 0), 3.0)); + assert_eq!(pen_ink("", 0.0, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 2.0), "no pen chosen yet: pen blue, 2 pt"); + assert_eq!(pen_ink("not a colour", 0.2, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 1.0)); + // A highlighter is wide and translucent, as the committed ink is painted. + let (c, w) = pen_ink("#FFFF00", 12.0, &theme); + assert_eq!((c, w), (Color32::from_rgb(0xFF, 0xFF, 0).gamma_multiply(0.45), 12.0)); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..5b7b4fb66 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Draw tab: the stroke drawn under the pointer while dragging a pen is the ink that will be committed, in the pen's colour and thickness (translucent for a highlighter), instead of always blue at 2 pt (#1786) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 2adfd5571fd5d4999f1a68080dd70ae3dc34cd14 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:58:23 -0600 Subject: [PATCH 2/2] #1786: ink is translucent by its width in points, while drawn and once committed Addresses CodeRabbit review on #3229, "Use the same translucency threshold as committed ink": the preview judged the pen by its width in points, the committed stroke by its width on screen, so at 50% a 12 pt highlighter turned opaque once committed and at 200% a 6 pt pen turned translucent. Both now use chartview::ink_color, which goes by the stroke's width in points, so the two agree and neither changes with the zoom. Test: ink_render::ink_translucency_follows_the_pen_not_the_zoom. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chartview.rs | 11 +++++-- crates/ui-egui/src/grid/interact.rs | 2 +- crates/ui-egui/tests/it/ink_render.rs | 45 +++++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/ink_render.rs diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 5c51d087b..40e3f0aa4 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -316,11 +316,12 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line))); } ShapeKind::Ink => { - let w = gridcraft_engine::cmd::draw::ink_width(sp) * geo.z; + let pt = gridcraft_engine::cmd::draw::ink_width(sp); + let w = pt * geo.z; let pts: Vec = gridcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect(); if pts.len() >= 2 { - let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line }; + let col = ink_color(line, pt); p.add(PathShape::line(pts, PathStroke::new(w, col))); } label = false; @@ -556,3 +557,9 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re } None } + +/// The colour ink is painted in: a highlighter (a stroke over 8 pt wide) is translucent, by its +/// width in points, so the live pen stroke and the committed ink agree at every zoom. +pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 { + if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line } +} diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index a94f08e6d..f7442fca4 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -257,7 +257,7 @@ pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model:: let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue); let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0); let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9)); - (if width > 8.0 { color.gamma_multiply(0.45) } else { color }, width) + (crate::chartview::ink_color(color, width), width) } fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { diff --git a/crates/ui-egui/tests/it/ink_render.rs b/crates/ui-egui/tests/it/ink_render.rs new file mode 100644 index 000000000..60ff42a4e --- /dev/null +++ b/crates/ui-egui/tests/it/ink_render.rs @@ -0,0 +1,45 @@ +//! Committed ink is painted as the pen previews it, at any zoom. + +use egui::Shape; +use egui::epaint::ColorMode; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +/// The colour of the ink stroke drawn from (40, 40) to (200, 40) sheet points, `width` points +/// wide: the two-point horizontal path whose width is `width` at its own scale (160 pt long). +fn painted_ink(h: &egui_kittest::Harness<'static, SheetApp>, width: f32) -> Option { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Path(p) if p.points.len() == 2 && (p.points[0].y - p.points[1].y).abs() < 0.01 => { + let z = (p.points[1].x - p.points[0].x).abs() / 160.0; + match p.stroke.color { + ColorMode::Solid(c) if z > 0.0 && (p.stroke.width - width * z).abs() < 0.01 => Some(c), + _ => None, + } + } + _ => None, + }) +} + +#[test] +fn ink_translucency_follows_the_pen_not_the_zoom() { + // A highlighter (over 8 pt) is translucent and a pen isn't, whatever the zoom: the committed + // stroke used its width on screen, so at 50% a 12 pt highlighter turned opaque and at 200% a + // 6 pt pen turned translucent, unlike the pen's preview. + for (width, zoom, translucent) in [(12.0_f32, 50, true), (6.0, 200, false), (12.0, 100, true), (6.0, 100, false)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("view.zoom", json!({"percent": zoom})).unwrap(); + s.execute("draw.stroke", json!({"points": [[40, 40], [200, 40]], "color": "#FFFF00", "width": width})).unwrap(); + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + crate::common::app(s), + ); + h.run_steps(4); + let c = painted_ink(&h, width).unwrap_or_else(|| panic!("the {width} pt stroke is painted at {zoom}%")); + assert_eq!(c.a() < 255, translucent, "{width} pt at {zoom}%: {c:?}"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..4608f843c 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -58,6 +58,7 @@ mod hyperlink_ctrl_click; mod hyperlink_dialog; mod in_cell_editor_size; mod in_cell_editor_wrap; +mod ink_render; mod insert_copied_cells; mod interaction; mod interface_scale;