Read Mode: a Page color menu shows the columns on white, sepia or a dark page

Word's Read Mode has View › Page Color with None, Sepia and Inverse.
The Read Mode toolbar now has a Page color menu beside Line spacing,
backed by the view.readPageColor command. None draws the columns on
white and does not show the document's page color or page picture.
Sepia draws them on Word's sepia paper (RGB 112, 66, 20 at 80%
transparency over white) and leaves the text's colors alone. Inverse
draws the dark page that View › Switch Modes draws.

The setting is screen only, is not saved, and starts at None on each
entry to Read Mode, as the other reading controls do. In Read Mode it
sets the paper even while Switch Modes is on. Print Layout and the
other views still show the document's page color, and Switch Modes
still darkens them.
This commit is contained in:
dotCooCoo
2026-10-24 12:22:04 -07:00
parent 254351c01b
commit b400a26aaa
7 changed files with 196 additions and 8 deletions
+29
View File
@@ -37,6 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
.pure(),
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -171,6 +174,7 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
s.view.read_column_width = None;
s.view.read_zoom = 1.0;
s.view.read_line_spacing = None;
s.view.read_page_color = ReadPageColor::None;
}
s.view.mode = m;
s.view.read_mode = read;
@@ -213,6 +217,31 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReadPageColor {
/// White paper; the document's page color and page picture are not shown.
#[default]
None,
/// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors.
Sepia,
/// A dark page with light text, as View › Switch Modes draws it.
Inverse,
}
fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
let color = match v.get("value").and_then(Value::as_str) {
Some("none") => ReadPageColor::None,
Some("sepia") => ReadPageColor::Sepia,
Some("inverse") => ReadPageColor::Inverse,
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
};
s.view.read_page_color = color;
Ok(json!({"pageColor": color}))
}
/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%.
pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option<Zoom> {
let fit = match v.fit.as_str() {
+4
View File
@@ -178,6 +178,9 @@ pub struct ViewState {
pub read_zoom: f32,
#[serde(skip)]
pub read_line_spacing: Option<f32>,
/// Read Mode's Page Color: the paper the reading columns are drawn on.
#[serde(skip)]
pub read_page_color: crate::cmd::view::ReadPageColor,
pub dark_mode: bool,
pub show_markup: bool,
pub track_changes_pane: bool,
@@ -265,6 +268,7 @@ impl Default for ViewState {
read_column_width: None,
read_zoom: default_read_zoom(),
read_line_spacing: None,
read_page_color: Default::default(),
dark_mode: false,
show_markup: true,
track_changes_pane: false,
+26
View File
@@ -5181,6 +5181,32 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
assert_eq!(s.view.read_line_spacing, None);
}
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
/// document alone, is not saved, and starts at None on each entry to Read Mode.
#[test]
fn reading_page_color_is_a_transient_view_setting() {
use crate::cmd::view::ReadPageColor;
let mut s = s();
let original = s.doc.clone();
let dirty = s.dirty;
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::None);
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
}
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
assert_eq!(s.doc, original);
assert_eq!(s.dirty, dirty);
run(&mut s, "view.printLayout", json!({}));
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::None);
}
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
#[test]
fn numeral_mode_sets_gets_and_rejects_junk() {
+56 -3
View File
@@ -460,12 +460,24 @@ pub struct RenderOptions {
pub dark: bool,
/// The grey that black becomes on a dark page (white paper turns this, not pure black).
pub dark_paper: u8,
/// Read Mode's Page Color: paper drawn instead of the document's page color and page
/// picture. `None` shows the document's page background. Screen only.
pub read_paper: Option<Rgb>,
}
/// Default grey for a dark page's paper.
pub const DARK_PAPER: u8 = 0x33;
/// Read Mode's Sepia paper: Word's RGB (112, 66, 20) at 80% transparency over white.
pub const SEPIA_PAPER: Rgb = Rgb(0xE2, 0xD9, 0xD0);
impl RenderOptions {
/// The on-screen color of the paper under `doc`'s pages: [`RenderOptions::read_paper`],
/// else the document's page color, else [`RenderOptions::paper`].
pub fn paper_of(&self, doc: &Document) -> Rgb {
self.ink(self.read_paper.or(doc.settings.page_color).unwrap_or(self.paper))
}
/// The on-screen colour for a document colour (see [`RenderOptions::dark`]).
pub fn ink(&self, c: Rgb) -> Rgb {
if !self.dark {
@@ -541,6 +553,7 @@ impl Default for RenderOptions {
text_darkening: false,
dark: false,
dark_paper: DARK_PAPER,
read_paper: None,
}
}
}
@@ -567,9 +580,11 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
let (w16, h16) = (cw as u16, ch as u16);
let mut ctx = context_pool::CONTEXTS.take(w16, h16);
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0));
ctx.set_paint(color(opts.paper_of(doc), 1.0));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
draw_page_picture(&mut ctx, doc, page, view);
if opts.read_paper.is_none() {
draw_page_picture(&mut ctx, doc, page, view);
}
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
if let Some(wm) = &doc.settings.watermark {
draw_watermark(&mut ctx, view, page, wm, opts);
@@ -602,7 +617,7 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
/// pictures and turned or rotated content are left out. The caret takes its colour from this
/// so it shows against whatever lies behind it (#299).
pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb {
let paper = opts.ink(doc.settings.page_color.unwrap_or(opts.paper));
let paper = opts.paper_of(doc);
if !(x.is_finite() && y.is_finite()) {
return paper;
}
@@ -1566,6 +1581,44 @@ mod tests {
assert_eq!(&img.pixels[0..3], &[255, 255, 255]);
}
/// Read Mode's Page Color: the reading paper replaces the document's page color and page
/// picture on screen, in the raster and under the caret; the text keeps its color on Sepia.
#[test]
fn read_paper_replaces_the_page_background() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut d = Document::from_text("Hello WordCraft");
d.media.insert("bg.png".into(), Arc::new(png));
d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40));
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let page = &l.pages[0];
let at = |img: &Rendered, x: u32, y: u32| {
let i = ((y * img.width + x) * 4) as usize;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]]
};
let sepia = RenderOptions { read_paper: Some(SEPIA_PAPER), ..Default::default() };
let white = RenderOptions { read_paper: Some(Rgb::WHITE), ..Default::default() };
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 5, 5), [0x10, 0x10, 0x40], "the page color");
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 5, 5), [0xE2, 0xD9, 0xD0]);
assert_eq!(at(&render_page(&d, page, 1.0, &white), 5, 5), [255, 255, 255]);
assert_eq!(background_at(&d, page, &sepia, 5.0, 5.0), SEPIA_PAPER);
assert_eq!(background_at(&d, page, &white, 5.0, 5.0), Rgb::WHITE);
let inverse = RenderOptions { read_paper: Some(Rgb::WHITE), dark: true, ..Default::default() };
assert_eq!(background_at(&d, page, &inverse, 5.0, 5.0), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER));
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "tile".into(), title: String::new() });
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 300, 300), [10, 200, 30], "the page picture");
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 300, 300), [0xE2, 0xD9, 0xD0]);
// The text is drawn in its own color on sepia paper: its darkest pixel is within 8 levels of its value on white.
d.settings.page_picture = None;
d.settings.page_color = None;
let darkest = |img: &Rendered| img.pixels.chunks(4).map(|p| p[0].min(p[1]).min(p[2])).min().unwrap_or(255);
let plain = render_page(&d, page, 2.0, &RenderOptions::default());
let on_sepia = render_page(&d, page, 2.0, &sepia);
assert!(darkest(&plain) < 0x40 && darkest(&on_sepia) <= darkest(&plain) + 8, "{} {}", darkest(&plain), darkest(&on_sepia));
}
/// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so
/// the header parses), with no real pixel data.
fn ihdr_only_png(w: u32, h: u32) -> Vec<u8> {
+64 -3
View File
@@ -780,6 +780,7 @@ fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let v = &app.session.view;
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
(v.read_mode, v.read_page_color).hash(&mut h);
app.session.prefs.markup.hash(&mut h);
dim_body.hash(&mut h);
format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark)
@@ -1059,7 +1060,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0));
}
// View › Switch Modes: dark paper, inverted document colours.
let dark_page = app.session.view.dark_mode;
// In Read Mode the Page color setting is used instead: Inverse draws the dark page, Sepia
// draws sepia paper and None draws white paper.
use wordcraft_engine::cmd::view::ReadPageColor;
let read_mode = app.session.view.read_mode;
let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode };
let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia;
let dark_paper = wordcraft_render::DARK_PAPER;
let show_ruler = app.session.view.ruler
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
@@ -1358,7 +1364,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
};
// Shadow and paper.
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
let paper = if dark_page {
Color32::from_gray(dark_paper)
} else if sepia_page {
let s = wordcraft_render::SEPIA_PAPER;
Color32::from_rgb(s.0, s.1, s.2)
} else {
Color32::WHITE
};
painter.rect_filled(visual_sr, 0.0, paper);
if tiled {
// Render before painting to keep the frame sharp immediately after an edit or
// zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling.
@@ -1941,7 +1955,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
{
let opts = page_render_options(app, dim_body);
let bg = if on_visible_page(&rects, &geo.bands, p) {
opts.ink(app.session.doc.settings.page_color.unwrap_or(opts.paper))
opts.paper_of(&app.session.doc)
} else {
let c = Tokens::get(ui.ctx()).canvas;
wordcraft_doc::props::Rgb(c.r(), c.g(), c.b())
@@ -3586,6 +3600,13 @@ fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_render::Rende
opts.display.revisions = app.session.prefs.markup.clone();
opts.dark = app.session.view.dark_mode;
opts.dark_paper = wordcraft_render::DARK_PAPER;
// In Read Mode the Page color sets the paper and the dark page, and Switch Modes is ignored.
if app.session.view.read_mode {
use wordcraft_engine::cmd::view::ReadPageColor;
let color = app.session.view.read_page_color;
opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE });
opts.dark = color == ReadPageColor::Inverse;
}
opts.display.dim_header = !dim_body;
opts.display.dim_body = dim_body;
opts
@@ -4061,6 +4082,46 @@ mod tests {
assert_eq!(at(&mut a, 2), Color32::WHITE, "again, from the cache");
}
/// Read Mode › View › Page Color: the reading columns are drawn on white, sepia or a dark
/// page, in place of the document's page color; other views keep the page color.
#[test]
fn read_mode_page_color_sets_the_reading_paper() {
use wordcraft_doc::props::Rgb;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default());
a.run("design.pageColor", json!({"color": "#102040"})).unwrap();
let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc);
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color");
let print_key = view_key(&a, false, None);
a.run("view.readMode", json!({})).unwrap();
assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored");
let none_key = view_key(&a, false, None);
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors");
let sepia_key = view_key(&a, false, None);
a.run("view.readPageColor", json!({"value": "inverse"})).unwrap();
assert!(page_render_options(&a, false).dark);
let d = wordcraft_render::DARK_PAPER;
assert_eq!(paper(&a), Rgb(d, d, d));
let inverse_key = view_key(&a, false, None);
let keys = [print_key, none_key, sepia_key, inverse_key];
for (i, k) in keys.iter().enumerate() {
assert!(!keys[..i].contains(k), "each paper has its own view key");
}
a.run("view.printLayout", json!({})).unwrap();
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "leaving Read Mode brings the page color back");
assert!(!page_render_options(&a, false).dark);
// With Switch Modes on, Read Mode's paper still follows the Page color: None is white and Sepia is sepia.
a.session.view.dark_mode = true;
a.run("view.readMode", json!({})).unwrap();
assert!(!page_render_options(&a, false).dark);
assert_eq!(paper(&a), Rgb::WHITE);
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
a.run("view.printLayout", json!({})).unwrap();
assert!(page_render_options(&a, false).dark, "Print Layout keeps Switch Modes");
}
/// #299: the caret's colour is worked out only when the caret is drawn: with text selected
/// there is no caret, so the page's draw list isn't built for it.
#[test]
+14
View File
@@ -75,6 +75,20 @@ fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) {
}
});
ui.separator();
use wordcraft_engine::cmd::view::ReadPageColor;
let colors =
[(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")];
let current = app.session.view.read_page_color;
let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1);
ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| {
for (color, name, value) in colors {
if ui.selectable_label(current == color, tl!(name)).clicked() {
let _ = app.run("view.readPageColor", json!({"value": value}));
ui.close();
}
}
});
ui.separator();
// Headings to jump to, in the Navigation pane beside the columns (Outline view
// is an editing layout, so it isn't offered here).
if ui.button(tl!("Navigation Pane")).clicked() {
+3 -2
View File
File diff suppressed because one or more lines are too long