Merge pull request #310 from vangel76/fix/sheet-right-to-left

Grid: mirror right-to-left sheets (#146)

# Conflicts:
#	crates/ui-egui/src/grid.rs
#	docs/localization-parity.md
This commit is contained in:
Brandon Thomas 2026-10-11 12:05:25 -04:00
commit 1302ec76a4
14 changed files with 285 additions and 41 deletions

View File

@ -188,12 +188,23 @@ pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
static TEXTURES: std::sync::LazyLock<Mutex<HashMap<(u32, usize), egui::TextureHandle>>> = 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<usize>,
pub rect: Rect,
}
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
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) {

View File

@ -121,7 +121,7 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option<egui::Pos2> {
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) {

View File

@ -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] {

View File

@ -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<LineIndex>,
ci: std::sync::Arc<LineIndex>,
@ -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<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));
}
}
}
}
#[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| {

View File

@ -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", "標準"),

View File

@ -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" => "보통",

View File

@ -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",

View File

@ -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" => "Обычные",

View File

@ -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" => "普通",

View File

@ -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);

View File

@ -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) {

View File

@ -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);

View File

@ -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());
}

View File

@ -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 |