diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index bb40a4a..bd4d5f8 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -188,12 +188,23 @@ pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect { static TEXTURES: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { +/// The shapes one floating object painted (indices into the painter's layer) and its logical +/// rectangle, so a right-to-left sheet can move it as a whole instead of mirroring it. +pub(crate) struct PaintedObject { + pub shapes: std::ops::Range, + pub rect: Rect, +} + +pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec { + let mut painted = Vec::new(); + let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect }); for sp in &sh.shapes { let r = anchor_rect(geo, sh, &sp.anchor); if !r.intersects(p.clip_rect()) { continue; } + let start = p.add(egui::Shape::Noop); + let mut label = true; let fill = sp.fill.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::TRANSPARENT); let line = sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY); match sp.kind { @@ -225,13 +236,13 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line }; p.add(PathShape::line(pts, PathStroke::new(w, col))); } - continue; + label = false; } ShapeKind::Icon => { if let Some(icon) = crate::icons::from_name(&sp.text) { crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill }); } - continue; + label = false; } ShapeKind::Line | ShapeKind::Arrow => { p.line_segment([r.left_top(), r.right_bottom()], Stroke::new(2.0, line)); @@ -243,7 +254,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: } } } - if !sp.text.is_empty() && !app.editing_text_box(sp.id) { + if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) { let job = egui::text::LayoutJob::simple( sp.text.clone(), FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())), @@ -254,15 +265,17 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: let pos = if sp.kind == ShapeKind::TextBox { r.min + vec2(4.0, 4.0) } else { r.center() - g.size() / 2.0 }; p.galley(pos, g, Color32::BLACK); } - if sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) { + if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) { selection_frame(p, r); } + done(start, r); } for im in &sh.images { let r = anchor_rect(geo, sh, &im.anchor); if !r.intersects(p.clip_rect()) { continue; } + let start = p.add(egui::Shape::Noop); let key = (im.id, im.data.len()); let mut cache = TEXTURES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !cache.contains_key(&key) @@ -282,12 +295,14 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: if app.selected_chart == Some(im.id) { selection_frame(p, r); } + done(start, r); } for ch in &sh.charts { let r = anchor_rect(geo, sh, &ch.anchor); if !r.intersects(p.clip_rect()) { continue; } + let start = p.add(egui::Shape::Noop); // Soft shadow + white card. p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18)); p.rect_filled(r, 0.0, Color32::WHITE); @@ -297,6 +312,10 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z); if app.selected_chart == Some(ch.id) { selection_frame(p, r); + } + // The source-data highlight below is in cell space: mirrored with the cells. + done(start, r); + if app.selected_chart == Some(ch.id) { // Highlight the source data like Excel does. for s in &ch.series { if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values) @@ -311,6 +330,7 @@ pub fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: } } } + painted } fn selection_frame(p: &Painter, r: Rect) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 041ef09..f3074ab 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -121,7 +121,7 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option { let sh = d.wb.active()?; let rect = app.grid.rect?; let geo = crate::grid::Geo::new(sh, rect, app.view().scroll); - Some(geo.cell_rect(sh, c).center()) + Some(geo.screen_cell_rect(sh, c).center()) } fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 689f103..40a25c4 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -230,6 +230,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: } else { (0, 1) }; + // Left/Right follow the screen on a right-to-left sheet. + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let dc = if rtl { -dc } else { dc }; if ed.can_point() { ui.input_mut(|i| { for k in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index aa40072..f335c9f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -113,6 +113,11 @@ pub struct Geo { pub frozen_w: f32, pub frozen_h: f32, pub scroll: egui::Vec2, + /// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical" + /// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s + /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by + /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). + pub rtl: bool, /// Prefix sums over custom row heights / column widths, built once per frame. ri: std::sync::Arc, ci: std::sync::Arc, @@ -132,7 +137,21 @@ impl Geo { 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 } + Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, rtl: sh.right_to_left, ri, ci } + } + /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. + pub fn mirror_x(&self, x: f32) -> f32 { + if self.rtl { self.rect.left() + self.rect.right() - x } else { x } + } + pub fn mirror_pos(&self, p: Pos2) -> Pos2 { + pos2(self.mirror_x(p.x), p.y) + } + pub fn mirror_rect(&self, r: Rect) -> Rect { + if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r } + } + /// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets). + pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { + self.mirror_rect(self.cell_rect(sh, c)) } pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { if col < self.fc { @@ -345,7 +364,14 @@ fn display_text( 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())); + // A right-to-left sheet has its vertical scrollbar on the left. + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let bar = 14.0_f32.min(available.width()); + let rect = if rtl { + Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) + } else { + Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom())) + }; let resp = ui.allocate_rect(rect, Sense::click_and_drag()); app.grid.rect = Some(rect); let Some(d) = app.session.active() else { return }; @@ -363,6 +389,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if scroll_delta != egui::Vec2::ZERO { let z = sh.zoom as f32 / 100.0 * DISPLAY_SCALE; let mut d = scroll_delta; + // A horizontal swipe moves the content the way the fingers go, which is towards + // higher columns on a mirrored sheet. + if sh.right_to_left { + d.x = -d.x; + } if modifiers.shift && d.x == 0.0 { d = vec2(d.y, 0.0); } @@ -402,9 +433,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if let Some(v) = app.view_mut() { *v = view; } - app.grid.cells_rect = Some(geo.cells); + app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); + // Everything up to the headers is painted in logical (left-to-right) coordinates. + let mirror_from = painter.add(egui::Shape::Noop); let mut cf = CfCache::new(); // Quadrants: (rows, cols, clip). let (sr0, sr1) = geo.scroll_rows(sh); @@ -438,7 +471,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { 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); + let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); if app.session.view_mode == "pageBreakPreview" || app.session.view_mode == "pageLayout" { paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); @@ -448,6 +481,10 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { paint_headers(&painter, &geo, sh, &sel, &t, &quads); paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); } + if geo.rtl { + let mirror_to = painter.add(egui::Shape::Noop); + mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); + } // Interaction. let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu); interact(app, ui, &resp, &geo, sh, &wb); @@ -459,7 +496,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { 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); + let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; + vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); @@ -468,8 +506,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { /// 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())); +fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { + let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); if track.height() <= 0.0 || track.width() <= 0.0 { return; } @@ -515,6 +553,59 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } } +/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left +/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction +/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index +/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture +/// isn't flipped. +fn mirror_painted(ctx: &egui::Context, layer: egui::LayerId, range: std::ops::Range, geo: &Geo, objects: &[crate::chartview::PaintedObject]) { + let axis = geo.rect.left() + geo.rect.right(); + ctx.graphics_mut(|g| { + let list = g.entry(layer); + for i in range { + let object = objects.iter().find(|o| o.shapes.contains(&i)); + list.mutate_shape(egui::layers::ShapeIdx(i), |cs| { + cs.clip_rect = geo.mirror_rect(cs.clip_rect); + match object { + Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)), + None => mirror_shape(&mut cs.shape, axis), + } + }); + } + }); +} + +/// Reflect one shape across the vertical line `x = axis / 2`. +fn mirror_shape(shape: &mut egui::Shape, axis: f32) { + use egui::Shape; + let flip = |p: &mut Pos2| p.x = axis - p.x; + match shape { + Shape::Noop | Shape::Callback(_) => {} + Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)), + Shape::Circle(c) => flip(&mut c.center), + Shape::Ellipse(e) => flip(&mut e.center), + Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip), + Shape::Path(p) => { + p.points.iter_mut().for_each(flip); + // Keep the preferred (clockwise) winding for filled polygons. + p.points.reverse(); + } + Shape::Rect(r) => { + r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom())); + let c = r.corner_radius; + r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw }; + } + Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip), + Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip), + _ => { + let b = shape.visual_bounding_rect(); + if b.is_finite() { + shape.translate(vec2(axis - b.right() - b.left(), 0.0)); + } + } + } +} + #[allow(clippy::too_many_arguments)] 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; @@ -1126,7 +1217,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & 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()); + // Hit-testing works in logical coordinates (see `Geo::rtl`). + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); let in_cells = pos.is_some_and(|p| geo.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()); @@ -1203,10 +1295,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & .ink .iter() .map(|q| { - pos2( + geo.mirror_pos(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)))); @@ -1287,7 +1379,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // 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 }; + let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return }; // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). if freeze_cols.contains(p) || freeze_rows.contains(p) { app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; @@ -1332,7 +1424,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } // Filter dropdown buttons. if is_filter_button(sh, geo, p) { - app.grid.filter_menu = Some((c.col, p)); + app.grid.filter_menu = Some((c.col, geo.mirror_pos(p))); return; } // Point mode while editing a formula: clicking inserts a reference. @@ -1598,9 +1690,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & 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)); + app.grid.header_menu = Some((geo.mirror_pos(p), rows)); } else { - app.grid.context_menu = Some(p); + app.grid.context_menu = Some(geo.mirror_pos(p)); } } @@ -1867,8 +1959,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: 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), + // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. + Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }), + Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }), Key::PageDown if !cmd => mv(app, page, 0), Key::PageUp if !cmd => mv(app, -page, 0), Key::PageDown => app.run_or_alert("sheet.next", json!({})), @@ -1943,7 +2036,8 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, 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)); + // Grows to the left on a right-to-left sheet. + let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); let fill = match st.fill.pattern { PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), _ => Color32::WHITE, @@ -2206,8 +2300,8 @@ fn paint_overlays( && !geo.hidden_under_frozen(sh, sel.active) { 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| { + let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { ui.set_max_width(220.0); if !dv.input_title.is_empty() { @@ -2230,17 +2324,21 @@ fn paint_overlays( } 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)); - } - }); - }); + let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { + ui.set_max_width(240.0); + ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK)); + ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK)); + for (a, t) in &cm.replies { + ui.separator(); + ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK)); + } + }); + }, + ); } // Hyperlink tooltip. if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c)) @@ -2292,8 +2390,9 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) }; let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); let r = geo.cell_rect(sh, c); + let (at, pivot) = (geo.mirror_pos(r.left_bottom()), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); let mut close = false; - let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { ui.set_min_width(r.width().max(100.0)); egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7d3ea42..e2d5a26 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -280,6 +280,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Remove Page Break", "改ページの解除"), ("Reset All Page Breaks", "すべての改ページを解除"), ("Print\nTitles", "印刷タイトル"), + ("Sheet\nRight-to-Left", "シートを右から左へ"), + ("Sheet Right-to-Left", "シートを右から左へ"), ("Narrow", "狭い"), ("Wide", "広い"), ("Normal", "標準"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b1ea0a5..e9dce77 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -123,6 +123,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Page Break" => "페이지 나누기 제거", "Reset All Page Breaks" => "모든 페이지 나누기 다시 설정", "Print\nTitles" => "인쇄\n제목", + "Sheet\nRight-to-Left" => "시트\n오른쪽에서 왼쪽으로", + "Sheet Right-to-Left" => "시트 오른쪽에서 왼쪽으로", "Narrow" => "좁게", "Wide" => "넓게", "Normal" => "보통", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index cd2be91..98b3823 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -124,6 +124,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Page Break" => "Remover Quebra de Página", "Reset All Page Breaks" => "Redefinir Todas as Quebras de Página", "Print\nTitles" => "Imprimir\nTítulos", + "Sheet\nRight-to-Left" => "Planilha da Direita\npara a Esquerda", + "Sheet Right-to-Left" => "Planilha da Direita para a Esquerda", "Narrow" => "Estreita", "Wide" => "Ampla", "Normal" => "Normal", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index eb61651..b92972f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -123,6 +123,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Page Break" => "Удалить разрыв страницы", "Reset All Page Breaks" => "Сбросить все разрывы страниц", "Print\nTitles" => "Печатать\nзаголовки", + "Sheet\nRight-to-Left" => "Лист\nсправа налево", + "Sheet Right-to-Left" => "Лист справа налево", "Narrow" => "Узкие", "Wide" => "Широкие", "Normal" => "Обычные", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 6ca33c7..acc0261 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -123,6 +123,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Page Break" => "删除分页符", "Reset All Page Breaks" => "重置所有分页符", "Print\nTitles" => "打印\n标题", + "Sheet\nRight-to-Left" => "工作表\n从右到左", + "Sheet Right-to-Left" => "工作表从右到左", "Narrow" => "窄", "Wide" => "宽", "Normal" => "普通", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 87bde2d..f5d69ad 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -101,6 +101,7 @@ pub enum Icon { Theme, Margins, Orient, + SheetRtl, Paper, PrintArea, Script, @@ -576,6 +577,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c)); g.rect(6.0, 2.0, 8.0, 6.0, Some(Color32::WHITE), Some(BLUE)); } + Icon::SheetRtl => { + // A sheet with an arrow running right to left. + g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c)); + g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light); + g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light); + g.line(&[(13.5, 13.0), (4.5, 13.0)], BLUE, 1.4); + g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], BLUE); + } Icon::PrintArea => { g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light)); g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], BLUE, 1.4); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 209bf76..8ae45f2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1440,6 +1440,15 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { act(app, "pageLayout.printHeadings", json!({"on": p})); } }); + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false); + if rtl { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + } + if r.clicked() { + act(app, "view.rightToLeft", json!({"on": !rtl})); + } } fn formulas(app: &mut SheetApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index 1e83f62..a0b30c0 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -129,7 +129,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr None } }); - let rect = crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells); + let rect = geo.mirror_rect(crate::chartview::anchor_rect(geo, sh, &shape.anchor).shrink(4.0).intersect(geo.cells)); if !rect.is_positive() { ed.rect = None; app.text_box_editor = Some(ed); diff --git a/crates/ui-egui/tests/right_to_left.rs b/crates/ui-egui/tests/right_to_left.rs new file mode 100644 index 0000000..3be2f3b --- /dev/null +++ b/crates/ui-egui/tests/right_to_left.rs @@ -0,0 +1,90 @@ +//! Sheet Right-to-Left: the grid is mirrored on screen and pointer/keyboard input follows it. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, RangeRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["first", 2], [3, "fourth"]]})).unwrap(); + session.execute("view.rightToLeft", json!({"on": true})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn geo(h: &Harness) -> Geo { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll) +} + +/// Where `a1` is drawn on screen. +fn point(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + geo(h).screen_cell_rect(sheet, CellRef::parse(a1).unwrap()).center() +} + +fn frame(h: &mut Harness, event: Event) { + h.input_mut().events.push(event); + h.run_steps(1); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn current(h: &Harness) -> RangeRef { + h.state().session.active().unwrap().selection.current() +} + +#[test] +fn column_a_is_drawn_on_the_right_and_clicks_hit_the_mirrored_cells() { + let mut h = harness(); + let g = geo(&h); + assert!(g.rtl); + let (a1, b1) = (point(&h, "A1"), point(&h, "B1")); + assert!(a1.x > b1.x, "A1 at {a1:?} should be right of B1 at {b1:?}"); + // Row headers sit on the right edge, so A1 ends where they begin. + let sheet_a1 = { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + g.screen_cell_rect(sheet, CellRef::parse("A1").unwrap()) + }; + assert!((sheet_a1.right() - (g.rect.right() - g.header_w)).abs() < 0.5); + + // Drag from C3 to F7 at their on-screen positions. + let (from, to) = (point(&h, "C3"), point(&h, "F7")); + frame(&mut h, Event::PointerMoved(from)); + frame(&mut h, button(from, true)); + frame(&mut h, Event::PointerMoved(to)); + frame(&mut h, button(to, false)); + h.run_steps(1); + assert_eq!(current(&h), RangeRef::parse("C3:F7").unwrap()); +} + +#[test] +fn left_and_right_arrows_follow_the_screen() { + let mut h = harness(); + h.key_press(Key::ArrowLeft); + h.run_steps(1); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("C2").unwrap()); + h.key_press(Key::ArrowRight); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("A2").unwrap()); +} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3b14bf5..133930b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -27,7 +27,10 @@ right-to-left sheets and full complex-script support. Part of - **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32; #142 is still open pending confirmation). There is no complex-text shaping: Arabic and Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left** - is a stored flag (`view.rightToLeft`) but the grid doesn't mirror (#146). IME input goes + (`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`) mirrors the grid (#146): + column A and the row headers on the right, the vertical scrollbar on the left; hit-testing, + arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box + (no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet. IME input goes through egui/winit and is unverified for Chinese, Japanese and Korean. - **PDF export** uses only the 14 standard PDF fonts: non-Latin text cannot export. @@ -43,7 +46,7 @@ separators, number/date formats, machine translation with agent review, and scri | Simplified Chinese (Mandarin) | zh-Hans | 266 (~12%) | no | CJK system-font fallback (#38); IME unverified; PDF can't export | no | menus only | 8–13 h | | Spanish | es | 0 (0%) | no | Latin: yes | no | none | 6–10 h | | Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h | -| Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI, RTL sheet flag not rendered** (#146) | no | none | 25–35 h | +| Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h | | Portuguese | pt-BR | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h | @@ -67,7 +70,7 @@ language (none so far) and of localized function names. boundary only (files keep en-US invariant names), locale number and date formats, list separators in CSV. 3. Text shaping and fallback: a shaping engine for grid, editor and PDF text (Arabic, Devanagari, - Thai), system font fallback for CJK, bidirectional runs, and a real right-to-left sheet layout; + Thai), system font fallback for CJK, bidirectional runs, right-to-left sheet tabs and printing; embedded subset fonts in PDF. ## Revision history @@ -75,5 +78,6 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | +| 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-10 | minor | Re-measured after #31, #104, #82, #38 merged: ribbon translated into ja, zh, ko, pt-BR and ru (266–268 labels, ~12%), system-language default, CJK/Thai/Hebrew font fallback; ~10% ready, 100–165 h | | 2026-10-10 | major | Created: English only, no catalog, ~2,200 strings, script-support gaps, open language PRs |