mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 02:22:13 +00:00
* #1500: shape fills keep gradients and transparency (model, DOCX read/write) A shape's, text box's and canvas's fill is now a `Fill` (solid with transparency, or a gradient kept as the file states it) instead of one opaque colour, so gradients and transparency survive opening and saving. - doc: `fill::Fill`/`Gradient` with sanitizing for hostile input; `.json` keeps writing opaque solid fills as `[r,g,b]` and reads both forms; `ShapeExtra::outline_fill` keeps an outline gradient. - docx read: `a:solidFill` with `a:alpha`, `a:gradFill` (stops, `a:lin`, `a:path`/`fillToRect`, `tileRect`, `rotWithShape`, `flip`), theme fill styles through `a:fillRef` 1-999/1001+ with `phClr`, `a:grpFill`, `satMod`, outline gradients, VML `v:fill` gradients and opacity, canvas backgrounds. - docx write: `a:solidFill` + `a:alpha`, `a:gradFill` as read. - engine/docbin/layout: call sites; drawing still uses the main colour (next commit draws gradients). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: draw shape gradients and transparency (layout, render, PDF) - layout: WordArt's paint becomes a shared `display::Paint` (solid, linear, radial); `paint_for` lays a fill over the shape's frame (linear at its angle, `scaled` honoured; path gradients circular from their focus to the farthest corner). Shapes, freeforms and canvas backgrounds use it. - render: one `set_paint` for shapes, paths and WordArt; radial gradients; soft edges fade the real fill. - pdf: axial and radial shadings with per-stop opacity. - Review fixes: nested groups without a fill pass theirs on to `a:grpFill`, text-box contents don't take a group's fill, `phClr` transparency only on `phClr` colours, VML focus ends consistent and opacity clamped, `rotWithShape`/`scaled` written only when set, Shape Outline clears an outline gradient, shared stop sanitizing. - docs: file-format, layout parity, gaps #1, roadmap progress log. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: shape.fill sets transparency and gradients on every selected shape - shape.fill (now cmd/shape_fill.rs) takes {"color", "transparency"?} or {"gradient": {stops, type, angle, focus, rotateWithShape}}; a colour alone keeps the transparency; values are sanitized; applies to every selected shape, text box and canvas as one undo step. - Formatting (shape.fill, shape.effects) keeps a Shift+click multi-selection, as in Word. - view.formatShapePane toggles the Format Shape pane. - Session::undo_mark/merge_undo_since: a pane drag that applies as it goes undoes in one step. - Helpers for the pane: params(fill), stop add/remove/move, directions, variations and presets (variation and preset colours provisional until compared with Word). - Catalog: Shape Fill Gradient, Format Shape Pane. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: Shape Fill › Gradient and the Format Shape pane - Shape Fill › Gradient: No Gradient, Light and Dark Variations of the shape's colour, More Gradients… (opens the pane). - Format Shape pane (right): No fill / Solid fill (colour, transparency) / Gradient fill (presets, type, direction, angle, stops bar with add/remove/drag and arrow keys, per-stop colour, position, transparency, rotate with shape). Drags preview live, one undo step. - Right-click › Format Shape… on shapes, text boxes and canvases. - Gradient swatches have accessible names; new labels translated (own wording, clean room). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: preset gradients are our own design (clean room) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: Gradient variations, starting gradient and directions as Word 365 does them Observed in Word 365 (behaviour and values only): - Shape Fill › Gradient: a No Gradient swatch, then 13 Light and 13 Dark Variations in a grid five wide, in Word's order; light stops tinted 66/44.5/23.5% with saturation ×1.6, dark shaded 30/67.5/100% with ×1.15; linear ones scaled; corners with their tile rectangle. - Format Shape › Gradient fill starts from the theme's first accent (four stops, 0/74/83/100%). - Corner directions in the pane get the same tile rectangle; Add/Remove sit by the stops bar. - shape.fill carries "scaled" and "tile"; docx exports color_modified (lumMod, lumOff, tint, shade, satMod) so variations use the reader's own colour maths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: review fixes for the shape fill ribbon controls - Gradient variations are built from the colour the shown variation came from, so picking variations in turn, or No Gradient, doesn't drift the colour. - A drag's undo mark is forgotten while the Format Shape pane is closed (a stale mark could merge later undo steps). - Dragging on the stops bar away from a handle no longer moves the selected stop; the arrow keys are taken by the bar (no focus moves or shape nudges). - Direction and Type changes keep a linear gradient's "scaled" and an imported tile rectangle; only a corner direction sets its tile. - Colour modifiers (lumMod, lumOff, tint, shade, satMod) live on Rgb in the doc crate, shared by the DOCX reader and the gradient builders. - Previews build their stop list once per swatch, not per vertex. - Translations for the pane's hint, direction and preset names. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: adapt to main: caret contrast samples gradient fills; upstream tests use Fill/Paint - Paint::at gives a paint's colour and opacity at a point; the caret's background sampling (#299) uses it for shape fills, now gradients or transparent, and the Format Shape previews use it too. - Upstream tests (#886 shapes survive saving, render) build fills as Fill/Paint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: adapt to main after the rebase: VML presets, rounded corners, new catalogs - vml_fill takes the shape's shapetype: a VML preset shape (#1377) takes its fill, opacity and gradient from its shapetype when it states none of its own, as it already took its colour. - Rounded-rectangle corners (#1476) and the gradient paints meet in render, PDF and the caret's background sampling; ShapeExtra keeps both `corner` and `outline_fill`. - Tests that landed on main since build fills as Fill/Paint (VML presets, cover pages, VML freeforms, path arrowheads, PDF and render shapes). - The 40 Shape Fill / Format Shape strings in the hr, ko, sv and vi catalogs, which main gained since. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: docs: shape gradients and transparency, Shape Fill › Gradient and the Format Shape pane Re-applied on main's docs after the rebase: progress log, file-format, layout and UI parity rows, gaps #1, status lines and revision history; parity checklist regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: a theme picture fill falls back on the reference colour; VML freeform opacity note Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: adapt to main after the second rebase: VML shapetype tests compare Fill; parity checklist regenerated Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: one VML fraction parser; a preset takes its gradient from its shapetype Review notes: story.rs's watermark/freeform opacity reuses shape_style's vml_fraction (clamped at the callers) instead of a second copy; a test pins a VML preset whose v:fill gradient comes from its v:shapetype, and the 65536ths form of a fraction. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: adapt to main after the third rebase: the shape duplicate preview test fills with Fill Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: review — harness test: right-click a shape, Format Shape… opens the pane and keeps the shape selected Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr * #1500: review — theme keeps every fill style a fillRef can reach (999 per list), still bounded Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: aiongg <aiongtaigi@gmail.com>
264 lines
13 KiB
Rust
264 lines
13 KiB
Rust
//! View tab and status bar: views, zoom, show/hide, panes.
|
||
|
||
use serde_json::{Value, json};
|
||
use wordcraft_doc::{Zoom, ZoomFit};
|
||
use wordcraft_layout::{NumeralMode, ViewMode};
|
||
|
||
use super::sel_result;
|
||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||
|
||
fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult {
|
||
let cur = *f(&mut s.view);
|
||
*f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur);
|
||
Ok(json!({"value": *f(&mut s.view)}))
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| {
|
||
let r = toggle(s, v, |x| &mut x.marks);
|
||
s.relayout();
|
||
r
|
||
})
|
||
.key("Mod+Shift+8 / Mod+8")
|
||
.pure(),
|
||
CommandSpec::new("view.printLayout", "Print Layout", "View › Views", |s, _| mode(s, ViewMode::Print, false)).key("Mod+Alt+P").pure(),
|
||
CommandSpec::new("view.webLayout", "Web Layout", "View › Views", |s, _| mode(s, ViewMode::Web, false)).pure(),
|
||
CommandSpec::new("view.draft", "Draft", "View › Views", |s, _| mode(s, ViewMode::Draft, false)).key("Mod+Alt+N").pure(),
|
||
// The body as an outline of its headings, with the Outlining tab (`outline.*`).
|
||
CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(),
|
||
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
|
||
CommandSpec::new("view.readColumnWidth", "Reading Column Width", "Reading › Layout", read_column_width)
|
||
.params(r#"{"value": "auto" | "narrow" | "medium" | "wide" | "page"}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.readTextSize", "Reading Text Size", "Reading › Layout", read_text_size)
|
||
.params(r#"{"value": percent (75–150)}"#)
|
||
.pure(),
|
||
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(),
|
||
// 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}"#)
|
||
.pure(),
|
||
// Focus and Full Screen View (#1678) fill the screen; turning one on turns the other off.
|
||
// Full Screen View has no key of its own: Customize Keyboard binds one.
|
||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| {
|
||
let r = toggle(s, v, |x| &mut x.focus_mode);
|
||
s.view.full_screen_view &= !s.view.focus_mode;
|
||
r
|
||
})
|
||
.params(r#"{"value"?: bool}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.fullScreenView", "Full Screen View", "View › Immersive", |s, v| {
|
||
let r = toggle(s, v, |x| &mut x.full_screen_view);
|
||
s.view.focus_mode &= !s.view.full_screen_view;
|
||
r
|
||
})
|
||
.params(r#"{"value"?: bool}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
|
||
// The drawing grid over the page (screen only; never printed or exported).
|
||
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines))
|
||
.params(r#"{"value"?: bool}"#)
|
||
.pure(),
|
||
// Outlines of table cells (screen only; never printed or exported).
|
||
CommandSpec::new("table.viewGridlines", "View Gridlines", "Table Layout › Table", |s, v| toggle(s, v, |x| &mut x.table_gridlines))
|
||
.params(r#"{"value"?: bool}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(),
|
||
CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom)
|
||
.params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.zoom100", "100%", "View › Zoom", |s, _| zoom(s, &json!({"value": 100}))).pure(),
|
||
CommandSpec::new("view.zoomIn", "Zoom In", "View › Zoom", |s, _| {
|
||
let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0);
|
||
zoom(s, &json!({"value": z * 100.0}))
|
||
})
|
||
.pure(),
|
||
CommandSpec::new("view.zoomOut", "Zoom Out", "View › Zoom", |s, _| {
|
||
let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1);
|
||
zoom(s, &json!({"value": z * 100.0}))
|
||
})
|
||
.pure(),
|
||
CommandSpec::new("view.onePage", "One Page", "View › Zoom", |s, _| zoom(s, &json!({"value": "onePage"}))).pure(),
|
||
CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(),
|
||
CommandSpec::new("view.pageWidth", "Page Width", "View › Zoom", |s, _| zoom(s, &json!({"value": "pageWidth"}))).pure(),
|
||
CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(),
|
||
// The interface theme itself (light, dark, or follow the OS), next to dark pages (#1861).
|
||
// The session can't paint: like split and new windows, this queues a request the front
|
||
// end applies to its theme setting (which persists per user). Without a value it toggles
|
||
// light and dark, as the manual switch did.
|
||
CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") {
|
||
None => {
|
||
s.ui_requests.push(json!({"toggle": "interfaceTheme"}));
|
||
Ok(json!({"value": "toggle"}))
|
||
}
|
||
Some(Value::String(name)) => match name.as_str() {
|
||
"light" | "dark" | "system" => {
|
||
s.ui_requests.push(json!({"interfaceTheme": name}));
|
||
Ok(json!({"value": name}))
|
||
}
|
||
_ => Err(CmdError::Params("interface theme: light|dark|system".into())),
|
||
},
|
||
_ => Err(CmdError::Params("interface theme: light|dark|system".into())),
|
||
})
|
||
.params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(),
|
||
CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(),
|
||
CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| {
|
||
toggle(s, v, |x| &mut x.accessibility_pane)
|
||
})
|
||
.params(r#"{"value"?: bool}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane))
|
||
.params(r#"{"value"?: bool}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.formatShapePane", "Format Shape Pane", "Shape Format › Shape Styles", |s, v| {
|
||
toggle(s, v, |x| &mut x.format_shape_pane)
|
||
})
|
||
.params(r#"{"value"?: bool}"#)
|
||
.pure(),
|
||
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
|
||
s.ui_requests.push(json!({"open": "newWindow"}));
|
||
sel_result(s)
|
||
})
|
||
.pure(),
|
||
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
|
||
s.ui_requests.push(json!({"toggle": "split"}));
|
||
sel_result(s)
|
||
})
|
||
.key("Mod+Alt+S")
|
||
.pure(),
|
||
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
|
||
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
|
||
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
|
||
CommandSpec::new("view.fullScreen", "Full Screen", "View › Window", |s, v| toggle(s, v, |x| &mut x.full_screen))
|
||
.params(r#"{"value"?: bool}"#)
|
||
.key(super::mac_or("Mod+Ctrl+F / F11", "F11"))
|
||
.when(full_screen_available)
|
||
.pure(),
|
||
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string())))
|
||
.pure(),
|
||
// Word › File › Options › Advanced › Numeral: how digits look on screen (display
|
||
// only; the document text keeps the typed codepoints).
|
||
CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") {
|
||
None => Ok(json!({"value": s.view.numeral})),
|
||
Some(Value::String(name)) => {
|
||
let Some(mode) = NumeralMode::parse(name) else {
|
||
return Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into()));
|
||
};
|
||
s.view.numeral = mode;
|
||
s.relayout();
|
||
Ok(json!({"value": s.view.numeral}))
|
||
}
|
||
Some(_) => Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())),
|
||
})
|
||
.params(r#"{"value"?: "arabic|hindi|context|system"}"#)
|
||
.pure(),
|
||
]
|
||
}
|
||
|
||
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
|
||
fn full_screen_available(_: &Session) -> Option<&'static str> {
|
||
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
|
||
}
|
||
|
||
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||
if read && !s.view.read_mode {
|
||
// Reading controls are session-only, and reset on each entry to the view.
|
||
s.view.read_column_width = None;
|
||
s.view.read_zoom = 1.0;
|
||
s.view.read_line_spacing = None;
|
||
}
|
||
s.view.mode = m;
|
||
s.view.read_mode = read;
|
||
s.relayout();
|
||
Ok(json!({"mode": m, "readMode": read}))
|
||
}
|
||
|
||
fn read_column_width(s: &mut Session, v: &Value) -> CmdResult {
|
||
let width = match v.get("value").and_then(Value::as_str) {
|
||
Some("auto") => None,
|
||
Some("narrow") => Some(180.0),
|
||
Some("medium") => Some(240.0),
|
||
Some("wide") => Some(350.0),
|
||
Some("page") => Some(612.0),
|
||
_ => return Err(CmdError::Params("column width must be auto, narrow, medium, wide, or page".into())),
|
||
};
|
||
s.view.read_column_width = width;
|
||
Ok(json!({"width": width}))
|
||
}
|
||
|
||
fn read_text_size(s: &mut Session, v: &Value) -> CmdResult {
|
||
let Some(pct) = v.get("value").and_then(Value::as_f64) else {
|
||
return Err(CmdError::Params("reading text size must be a percentage".into()));
|
||
};
|
||
if !pct.is_finite() || !(75.0..=150.0).contains(&pct) {
|
||
return Err(CmdError::Params("reading text size must be 75–150%".into()));
|
||
}
|
||
s.view.read_zoom = pct as f32 / 100.0;
|
||
Ok(json!({"percent": pct}))
|
||
}
|
||
|
||
fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||
let value = v.get("value");
|
||
let spacing = match value {
|
||
Some(Value::String(s)) if s == "document" => None,
|
||
Some(Value::Number(n)) if matches!(n.as_f64(), Some(1.0 | 1.25 | 1.5 | 2.0)) => n.as_f64().map(|n| n as f32),
|
||
_ => return Err(CmdError::Params("reading line spacing must be document, 1, 1.25, 1.5, or 2".into())),
|
||
};
|
||
s.view.read_line_spacing = spacing;
|
||
Ok(json!({"spacing": spacing}))
|
||
}
|
||
|
||
/// 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() {
|
||
"onePage" => Some(ZoomFit::OnePage),
|
||
"pageWidth" => Some(ZoomFit::PageWidth),
|
||
"textWidth" => Some(ZoomFit::TextWidth),
|
||
_ => None,
|
||
};
|
||
let percent = if v.zoom.is_finite() { (v.zoom * 100.0).round().clamp(10.0, 500.0) as u32 } else { 100 };
|
||
Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some())
|
||
}
|
||
|
||
/// Show an opened document at the zoom it was saved with.
|
||
pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) {
|
||
let fit = match z.fit {
|
||
Some(ZoomFit::OnePage) => "onePage",
|
||
Some(ZoomFit::PageWidth) => "pageWidth",
|
||
Some(ZoomFit::TextWidth) => "textWidth",
|
||
None => "",
|
||
};
|
||
s.view.zoom = z.percent.clamp(10, 500) as f32 / 100.0;
|
||
s.view.fit = fit.into();
|
||
s.view.multi_page = false;
|
||
s.view.single_column = fit == "onePage" || fit == "pageWidth";
|
||
}
|
||
|
||
fn zoom(s: &mut Session, v: &Value) -> CmdResult {
|
||
match v.get("value") {
|
||
Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => {
|
||
s.view.fit = f.clone();
|
||
s.view.multi_page = f == "multiplePages";
|
||
s.view.single_column = f == "onePage" || f == "pageWidth";
|
||
}
|
||
Some(x) => {
|
||
let pct = x.as_f64().ok_or_else(|| CmdError::Params("zoom percent or fit mode".into()))? as f32;
|
||
if !(10.0..=500.0).contains(&pct) {
|
||
return Err(CmdError::Params("zoom must be 10–500%".into()));
|
||
}
|
||
s.view.zoom = pct / 100.0;
|
||
s.view.fit.clear();
|
||
s.view.multi_page = false;
|
||
}
|
||
None => {
|
||
s.ui_requests.push(json!({"open": "zoom"}));
|
||
}
|
||
}
|
||
Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit}))
|
||
}
|