diff --git a/apps/README.md b/apps/README.md index 5451712c..f6c923f0 100644 --- a/apps/README.md +++ b/apps/README.md @@ -230,7 +230,10 @@ See [capabilities and execution boundaries](../docs/capabilities.md). on the dev fixture, and the shipped bundle with no engine. In a hidden desktop shell on Linux (headless weston) with the real `deck` engine of #503: a deck made from an outline, text edited in place, a format, a chart, a theme, notes, the sorter, save, export and Present, light then dark, then a restart. The model tests pass (`cargo test --locked - -p octosense-shell --lib quickdeck_model`). + -p octosense-shell --lib quickdeck_model`). The inspector's states (#507: + a box's anchor and autofit, a chart's elements, a table's options, the + colour set, font pair and footer, following a change and its undo) were + verified the same day, in `card-host` and with the real engine. - **Writer** (2026-10-09). In a hidden desktop shell built from this tree (`MAKEPAD_WM_TEST_APP=writer`, a hidden `--remote` run, light and dark), with the real `word` engine working in Writer's own storage: a document diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index 2792b4d2..f7af2b4b 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -179,7 +179,9 @@ WebView 页面、图库导出、邮件、新闻、Glance 或单次模型调用 在 App Hub 的 `card-host` 中(浅色与深色):开发夹具上每个设计过的页面和状态,以及没有 引擎时随 Shell 发布的 bundle。在 Linux 上隐藏运行的桌面 Shell 中(无头 weston),使用 #503 的真实 `deck` 引擎:从大纲生成演示文稿、原地编辑文本、格式、图表、主题、备注、浏览视图、保存、导出和放映,先浅色后深色,然后重启。模型测试通过(`cargo test --locked - -p octosense-shell --lib quickdeck_model`)。 + -p octosense-shell --lib quickdeck_model`)。检查器的状态(#507:文本框的垂直对齐和自动调整、 + 图表的元素、表格的选项、配色方案、字体搭配和页脚,以及它们跟随改动和撤销)于同日在 + `card-host` 中和真实引擎上验证。 - **Writer**(2026-10-09)。在用本仓库构建的隐藏桌面 Shell 中(`MAKEPAD_WM_TEST_APP=writer`, 隐藏窗口的 `--remote` 运行,浅色与深色),使用在 Writer 自己存储中工作的真实 `word` 引擎: 通过 instrument 输入的文档被保存为 Word 文档(`word.convert` 转换 Writer 写到 `work/` 的 diff --git a/apps/quickdeck/README.md b/apps/quickdeck/README.md index 6bb68292..8acc2cb6 100644 --- a/apps/quickdeck/README.md +++ b/apps/quickdeck/README.md @@ -32,7 +32,7 @@ review decision that applies. | Toolbar | Slides and Sorter; Text, Picture, Shape, Table, Chart (each opens its insert strip); Undo and Redo; Save, Export and Present | | Navigator | New slide, the sections (each folds), numbered thumbnails (the current one outlined, a hidden one dimmed) | | Canvas | The current slide, the engine's render at the canvas's width, with the selection's frame and handles; the pill: previous, "5 / 12", next, zoom out, the zoom, zoom in, Fit | -| Inspector | Format (what the selection is decides what shows: text, box, shape, picture, table, chart, arrange), Layout (the eleven layouts, background, transition, skip), Theme (themes, colours, fonts, slide size, footer), Notes | +| Inspector | Format (what the selection is decides what shows: text, box, shape, picture, table, chart, arrange), Layout (the eleven layouts, background, transition, skip), Theme (themes, colours, fonts, slide size, footer), Notes. Every switch and mark shows what the deck has: a box's anchor and autofit (`deck.format_state`), a chart's elements and palette and a table's options (`deck.slide`), the colour set, font pair and footer (`deck.open`, `deck.state`); a change shows at once, the service's next answer confirms it, and after Undo or Redo the deck's own come back without reopening | | Status line | Saved or Edited, "Slide 5 of 12 · 16:9" (in the sorter, "12 slides · 2 selected"), and what the app's storage holds against what it may hold, from the shell's `files.status` | The designs are drawn at 1536 x 1024, and the manifest asks the desktop to @@ -170,9 +170,9 @@ exceeded` in its log); `--budget-ms 1000` raises the shell's budget | Run | What it covers | | --- | --- | | `missing` | `card-host` with the shipped bundle: no engine and no files service, on Home, in a deck's tab and on the outline page with its Draft with AI | -| `fixture` | `card-host` with the dev fixture: every designed screen at the designs' 1536 x 1024, light and dark, each grab beside its design in `compare/` | +| `fixture` | `card-host` with the dev fixture: every designed screen at the designs' 1536 x 1024, light and dark, each grab beside its design in `compare/`, then a table's options and the footer; the inspector's switches and marks are checked against what the stand-in reports, and a table option and the footer's date against what it reports after a change and its Undo | | `states` | The dev fixture's damaged, protected, held and unsupported presentations, the unsaved-changes bar, storage full and the empty library of a first run | -| `shell` | A deck made from an outline with the real engine, then edited: text in place, bold, a chart, undo and redo, a layout, a theme, notes, the sorter, save, export and Present; light, then dark through the shell's own style menu | +| `shell` | A deck made from an outline with the real engine, then edited: text in place, bold, a chart, undo and redo, a layout, a theme, notes, the sorter, save, export and Present; light, then dark through the shell's own style menu. In the light pass the inspector's states follow real changes and their Undo: a box's anchor and autofit, a chart's data labels, a table's total row, the colour set and the footer, each read again from the engine (the shape chosen again, or the deck's design asked again) | | `restart` | The same home again: the library, the deck's cover and slides | The dev fixture never ships. `dev-fixture/engine.splash` is a stand-in for @@ -182,8 +182,12 @@ the deck engine that answers SERVICE.md's methods as the service does; designs' sample deck (the Northwind Q3 board update, its slides drawn as pictures from the designs' own sample text), five more decks for Home, the layout pictures, and what the stand-in answers (the engine's real themes, -colour and font schemes, shapes and fonts); `--states`, `--full` and -`--empty` make the other runs' libraries. +colour sets with their colours, font pairs, shapes and fonts, and each box's, +chart's, table's and the deck's states as #507 reports them); the stand-in +keeps an undo history of the deck's data. `--states`, `--full` and `--empty` +make the other runs' libraries. `--bundle DIR --grabs-only` takes the same +frames of another bundle (main's, for a before and after) without checking +the states. ```sh python3 apps/quickdeck/dev-fixture/make_fixture.py /os.quickdeck @@ -192,7 +196,9 @@ python3 apps/quickdeck/dev-fixture/make_fixture.py /os.quick ## Status See [tests/evidence/v2-20261011](tests/evidence/v2-20261011/README.md) for -the frames, side-by-sides and receipt. +v2's frames, side-by-sides and receipt, and +[tests/evidence/inspector-states-20261011](tests/evidence/inspector-states-20261011/README.md) +for the inspector's states (#507). - **Verified on 11 Oct 2026, in `card-host`** (macOS, Apple silicon, at the runtime's 64 ms budget): the `missing`, `fixture` and `states` runs, light @@ -204,6 +210,14 @@ the frames, side-by-sides and receipt. overrun. The shell opened Quick Deck at 1296 x 698 points (an app area of 1292 x 662). The model tests pass there (22; 23 with `--features app-hub`, which adds the manifest's window hint). +- **Verified on 11 Oct 2026, the inspector's states** (#507): in `card-host`, + the `fixture` and `states` runs, light and dark, at the 64 ms budget, the + switches and marks checked against the stand-in; with the real engine on + Linux (shell built from this branch on main eb2fd5d1, which has #507), the + `shell` run's light pass changed a box's anchor and autofit, a chart's + data labels, a table's total row, the colour set and the footer, read each + back from the engine, then undid each and read the deck's own back; then + dark and `restart`. The model tests pass (27). - **Not verified**: a real import from the device (a hidden window on macOS has the dialog refused, and the Linux host has no dialog helper), so the import guard's refusals through the app; Draft with AI with a real provider @@ -214,7 +228,7 @@ the frames, side-by-sides and receipt. - **Known**: the shell's window is smaller than the designs' 1536 x 1024, so the theme panel, Export and the outline scroll. Theme previews draw the app's own font (OctoSense bundles no theme fonts). A box being edited - shows plain lines without bullets. The colour schemes no theme is named - after show their names only, and the current colours and fonts are - inferred from the theme: the service doesn't report them yet. Present - fills the app's window, not the screen. + shows plain lines without bullets. A built-in theme's own font pair is + named after the theme, so the pair with the same families is marked (Harbor's + fonts are Classic's); an imported deck's own colours or fonts are named in a + hint. Present fills the app's window, not the screen. diff --git a/apps/quickdeck/README.zh-CN.md b/apps/quickdeck/README.zh-CN.md index 7744d27f..15654308 100644 --- a/apps/quickdeck/README.zh-CN.md +++ b/apps/quickdeck/README.zh-CN.md @@ -16,7 +16,7 @@ Quick Deck(`os.quickdeck`)是 OctoSense 桌面版的演示文稿应用:演 | 工具栏 | Slides 和 Sorter;Text、Picture、Shape、Table、Chart(各自打开插入条);Undo 和 Redo;Save、Export 和 Present | | 导航栏 | New slide、各分节(可折叠)、带编号的缩略图(当前幻灯片有外框,隐藏的幻灯片变淡) | | 画布 | 当前幻灯片,即引擎按画布宽度渲染的图片,以及选中对象的边框和控制点;浮动条:上一张、"5 / 12"、下一张、缩小、缩放比例、放大、Fit | -| 检查器 | Format(显示什么取决于选中的对象:文本、文本框、形状、图片、表格、图表、排列)、Layout(十一种版式、背景、切换效果、放映时跳过)、Theme(主题、配色、字体、幻灯片尺寸、页脚)、Notes | +| 检查器 | Format(显示什么取决于选中的对象:文本、文本框、形状、图片、表格、图表、排列)、Layout(十一种版式、背景、切换效果、放映时跳过)、Theme(主题、配色、字体、幻灯片尺寸、页脚)、Notes。每个开关和标记都显示演示文稿的实际设置:文本框的垂直对齐和自动调整(`deck.format_state`)、图表的元素和配色以及表格的选项(`deck.slide`)、配色方案、字体搭配和页脚(`deck.open`、`deck.state`);改动会立即显示,服务的下一次应答予以确认,撤销或重做后无需重新打开即显示演示文稿自己的设置 | | 状态栏 | Saved 或 Edited、"Slide 5 of 12 · 16:9"(浏览视图中为 "12 slides · 2 selected"),以及应用存储已用量与上限,数据来自 Shell 的 `files.status` | 设计图按 1536 x 1024 绘制,清单请求桌面以这个尺寸打开窗口(`"window": {"width": 1536, "height": 1024}`,App Hub 的窗口提示,需要 `"schema_minor": 1`)。桌面会按自身大小收窄:在 Linux 上隐藏运行的 Shell(1400 x 900 窗口)中,Quick Deck 以 1296 x 698 点打开。窗口宽度小于 1180 点时,导航栏缩窄到 200 点,检查器改由工具栏中的 Inspector 按钮在画布上方打开(**未验证**:没有任何一次运行使用这么窄的窗口)。 @@ -88,12 +88,12 @@ python3 apps/quickdeck/tests/ui.py --shell target/debug/octosense --lock - | 运行 | 覆盖内容 | | --- | --- | | `missing` | `card-host` 加随 Shell 发布的 bundle:没有引擎也没有文件服务,覆盖 Home、演示文稿标签以及大纲页面及其 Draft with AI | -| `fixture` | `card-host` 加开发夹具:每个设计过的页面,按设计图的 1536 x 1024,浅色和深色,每张截图与其设计图并排放在 `compare/` 中 | +| `fixture` | `card-host` 加开发夹具:每个设计过的页面,按设计图的 1536 x 1024,浅色和深色,每张截图与其设计图并排放在 `compare/` 中,然后是表格的选项和页脚;检查器的开关和标记与替身报告的内容核对,一个表格选项和页脚的日期在改动及撤销后与替身报告的内容核对 | | `states` | 开发夹具中损坏、受保护、被暂缓和不支持的演示文稿,未保存更改提示条,存储已满,以及首次运行时空的演示文稿库 | -| `shell` | 用真实引擎从大纲生成一个演示文稿,然后编辑:原地编辑文本、加粗、图表、撤销和重做、版式、主题、备注、浏览视图、保存、导出和放映;先浅色,再通过 Shell 自己的样式菜单切到深色 | +| `shell` | 用真实引擎从大纲生成一个演示文稿,然后编辑:原地编辑文本、加粗、图表、撤销和重做、版式、主题、备注、浏览视图、保存、导出和放映;先浅色,再通过 Shell 自己的样式菜单切到深色。浅色流程中,检查器的状态跟随真实的改动及其撤销:文本框的垂直对齐和自动调整、图表的数据标签、表格的汇总行、配色方案和页脚,每一项都重新从引擎读取(重新选中形状,或重新查询演示文稿的设计) | | `restart` | 同一个主目录再次启动:演示文稿库、演示文稿的封面和幻灯片 | -开发夹具不会发布。`dev-fixture/engine.splash` 是 deck 引擎的替身,像服务一样应答 SERVICE.md 中的方法;`tests/ui.py` 在 bundle 的临时副本中用它替换 `engine()`。`dev-fixture/make_fixture.py` 在应用存储中填入已批准设计图中的示例演示文稿(Northwind 第三季度董事会汇报,其幻灯片用设计图自己的示例文字画成图片)、Home 上的另外五个演示文稿、版式图片,以及替身应答的内容(引擎真实的主题、配色和字体方案、形状和字体);`--states`、`--full` 和 `--empty` 生成其他运行使用的演示文稿库。 +开发夹具不会发布。`dev-fixture/engine.splash` 是 deck 引擎的替身,像服务一样应答 SERVICE.md 中的方法;`tests/ui.py` 在 bundle 的临时副本中用它替换 `engine()`。`dev-fixture/make_fixture.py` 在应用存储中填入已批准设计图中的示例演示文稿(Northwind 第三季度董事会汇报,其幻灯片用设计图自己的示例文字画成图片)、Home 上的另外五个演示文稿、版式图片,以及替身应答的内容(引擎真实的主题、带颜色的配色方案、字体搭配、形状和字体,以及 #507 报告的每个文本框、图表、表格和演示文稿的状态);替身为演示文稿的数据保留撤销历史。`--states`、`--full` 和 `--empty` 生成其他运行使用的演示文稿库。`--bundle DIR --grabs-only` 为另一个 bundle(例如 main 上的,用于前后对比)截取同样的截图,但不核对状态。 ```sh python3 apps/quickdeck/dev-fixture/make_fixture.py /os.quickdeck @@ -101,9 +101,10 @@ python3 apps/quickdeck/dev-fixture/make_fixture.py /os.quick ## 状态 -截图、并排对比和回执见 [tests/evidence/v2-20261011](tests/evidence/v2-20261011/README.zh-CN.md)。 +第 2 版的截图、并排对比和回执见 [tests/evidence/v2-20261011](tests/evidence/v2-20261011/README.zh-CN.md),检查器状态(#507)的见 [tests/evidence/inspector-states-20261011](tests/evidence/inspector-states-20261011/README.zh-CN.md)。 - **2026 年 10 月 11 日在 `card-host` 中验证**(macOS,Apple 芯片,运行时默认的 64 ms 预算):`missing`、`fixture` 和 `states` 三种运行,浅色与深色,日志中没有脚本错误。 - **2026 年 10 月 11 日用真实引擎验证**(Linux,运行在无头 weston 上的隐藏桌面 Shell,由基于 main 202d7cc8 的本分支构建,main 已包含 #503 的 deck 服务):`shell` 运行(先浅色后深色)和 `restart`,使用运行时默认的 64 ms 预算,没有脚本错误也没有超时。Shell 以 1296 x 698 点打开 Quick Deck(应用区域 1292 x 662)。模型测试在那里通过(22 个;加 `--features app-hub` 时 23 个,多出的是清单的窗口提示)。 +- **2026 年 10 月 11 日验证检查器的状态**(#507):在 `card-host` 中,`fixture` 和 `states` 运行,浅色与深色,64 ms 预算,开关和标记与替身核对;在 Linux 上用真实引擎(Shell 由基于 main eb2fd5d1 的本分支构建,main 已包含 #507),`shell` 运行的浅色流程改动了文本框的垂直对齐和自动调整、图表的数据标签、表格的汇总行、配色方案和页脚,每一项都从引擎读回,再逐一撤销并读回演示文稿自己的设置;然后是深色流程和 `restart`。模型测试通过(27 个)。 - **未验证**:从本设备真正导入文件(macOS 上隐藏窗口的对话框会被拒绝,Linux 主机没有对话框助手),因此也未验证导入防护经由应用显示的拒绝;用真实提供方的 Draft with AI(只验证了没有提供方时的回答);macOS 上的隐藏 Shell;用真实引擎插入图片、表格、形状库、背景、长备注时的放映备注条以及图片导出(这些页面由开发夹具覆盖);宽度小于 1180 点的窗口;其他屏幕尺寸;手机。 -- **已知问题**:Shell 的窗口小于设计图的 1536 x 1024,所以主题面板、导出和大纲页面需要滚动。主题预览用应用自己的字体绘制(OctoSense 不附带主题字体)。正在编辑的文本框显示没有项目符号的纯文本行。没有同名主题的配色方案只显示名称,当前配色和字体由主题推断:服务目前还不报告它们。放映填满的是应用窗口,而不是整个屏幕。 +- **已知问题**:Shell 的窗口小于设计图的 1536 x 1024,所以主题面板、导出和大纲页面需要滚动。主题预览用应用自己的字体绘制(OctoSense 不附带主题字体)。正在编辑的文本框显示没有项目符号的纯文本行。内置主题自己的字体搭配以主题命名,所以标记的是字体相同的那组搭配(Harbor 的字体与 Classic 相同);导入的演示文稿自己的配色或字体会在提示中写明名称。放映填满的是应用窗口,而不是整个屏幕。 diff --git a/apps/quickdeck/bundle/main.splash b/apps/quickdeck/bundle/main.splash index b2af6953..4a59dbae 100644 --- a/apps/quickdeck/bundle/main.splash +++ b/apps/quickdeck/bundle/main.splash @@ -684,10 +684,74 @@ fn open_head(state){ if state == "full" { return "Quick Deck has " + MAX_OPEN + " decks open" } "Couldn't open this deck" } +// The inspector's states (SERVICE.md "Inspector states (#507)"): a control +// shows a change on its way (`pending`), else what the service reports, else +// the engine's default (deckcraft model/src/chart.rs, table.rs). +fn pending_or(pending, known, key, fallback){ + let p = optional(pending, key, nil) + if p != nil { return p } + optional(known, key, fallback) +} +// A legend shows unless the chart says "none" (the service's word for off). +fn legend_shown(legend){ + if legend == nil { return false } + let l = "" + legend + l != "" && l != "none" +} +fn chart_state(ch, pending){ + let title = optional(pending, "title", nil) + if title == nil { title = ("" + optional(ch, "title", "")) != "" } + let palette = "" + pending_or(pending, ch, "palette", "colorful1") + {title: title == true legend: legend_shown(pending_or(pending, ch, "legend", "b")) + data_labels: pending_or(pending, ch, "data_labels", false) == true + gridlines: pending_or(pending, ch, "gridlines", true) == true + palette: palette mono: palette.search("monochrome") == 0} +} +// A table's six style options, as deck.table {op: "options"} takes them. +fn table_options(tb, pending){ + let o = optional(tb, "options", {}) + {header_row: pending_or(pending, o, "header_row", true) == true + total_row: pending_or(pending, o, "total_row", false) == true + banded_rows: pending_or(pending, o, "banded_rows", true) == true + first_column: pending_or(pending, o, "first_column", false) == true + last_column: pending_or(pending, o, "last_column", false) == true + banded_columns: pending_or(pending, o, "banded_columns", false) == true} +} +// A text box's vertical alignment and autofit as format_state reports them +// (an imported deck may also say justified or distributed). +fn box_state(fmt){ + let autofit = "" + optional(fmt, "autofit", "") + {anchor: "" + optional(fmt, "anchor", "") autofit: autofit shrink: autofit == "shrink"} +} +// The deck's footer, in the keys deck.design {footer} takes. +fn footer_state(f, pending){ + {slide_numbers: pending_or(pending, f, "slide_numbers", false) == true + date: pending_or(pending, f, "date", false) == true + footer: pending_or(pending, f, "footer", false) == true + footer_text: "" + pending_or(pending, f, "footer_text", "")} +} +// The colour set to mark: one chosen and on its way, else the deck's +// (deck.open, deck.state), else its theme's own. +fn scheme_mark(chosen, scheme, theme){ + if ("" + chosen) != "" { return "" + chosen } + if ("" + scheme) != "" { return "" + scheme } + "" + theme +} +// The font pair to mark: one chosen and on its way, else the deck's by name, +// else the pair with the deck's families (a built-in theme's own pair is +// named after the theme: Harbor's is Classic's Merriweather and Open Sans). +fn font_mark(chosen, scheme, pairs, major, minor){ + if ("" + chosen) != "" { return "" + chosen } + for f in pairs { if ("" + optional(f, "name", "")) == ("" + scheme) { return "" + scheme } } + for f in pairs { + if ("" + optional(f, "major", "")) == ("" + major) && ("" + optional(f, "minor", "")) == ("" + minor) { return "" + optional(f, "name", "") } + } + "" +} fn open_detail(state){ if state == "protected" { return "Quick Deck doesn't open password-protected files: OctoSense keeps passwords on its own sheets, and there isn't one for presentations." } if state == "held" { return "It holds video or audio, or parts larger or more unusual than the deck engine reads safely yet (#448). Remove the media, or save a smaller copy, in the app that made it, then open it again." } - if state == "unsupported" { return "Older PowerPoint (.ppt), Keynote (.key), OpenDocument (.odp) and PDF files can't be opened. Save it as .pptx in the app that made it." } + if state == "unsupported" { return "Word documents, Excel workbooks, older PowerPoint (.ppt), Keynote (.key), OpenDocument (.odp) and PDF files can't be opened here. Save the presentation as .pptx in the app that made it." } if state == "too_large" { return "It's beyond what the deck engine opens on this device." } if state == "full" { return "Close another deck's tab, then open this one again." } "" @@ -1454,6 +1518,7 @@ fn new_tab(d){ grid: nil grid_for: nil cell: [0, 0] alt_text: "" shape_cat: "" table_rows: 3 table_cols: 4 chart_type: "column" bg_kind: "theme" transition_ms: 700 + color_scheme: "" font_scheme: "" footer: nil import: d.import} } fn open_deck(d){ @@ -1507,6 +1572,7 @@ fn took_open(t, v){ t.layouts = optional(v, "layouts", []) t.sections = optional(v, "sections", []) t.theme = "" + optional(v, "theme", "") + took_design(t, v) t.rev = optional(v, "rev", 0) t.edited = optional(v, "edited", false) t.can_undo = optional(v, "can_undo", false) @@ -1535,6 +1601,61 @@ fn took_open(t, v){ pump() }) } +// The deck's design as the document has it (deck.open, deck.state): its +// theme, colour set, font pair and footer. What the person picked and is on +// its way gives way to it. +fn took_design(t, v){ + let theme = optional(v, "theme", nil) + if theme != nil { t.theme = "" + theme } + t.color_scheme = "" + optional(v, "color_scheme", "") + t.font_scheme = "" + optional(v, "font_scheme", "") + t.footer = optional(v, "footer", nil) + t.colors = "" + t.fonts = "" + t.footer_pending = {} + // deck.open's missing_fonts holds for the fonts the deck opened with. + let key = t.theme + "|" + t.font_scheme + if optional(t, "fonts_key", nil) == nil { t.fonts_key = key } + t.fonts_changed = key != t.fonts_key + if t.fonts_changed { ask_fonts() } +} +// The deck's fonts this device draws with similar ones: what deck.open said, +// until the theme or the font pair changes; then those of the current pair's +// families that deck.fonts doesn't list. +fn missing_now(t){ + if !optional(t, "fonts_changed", false) { return optional(t, "missing_fonts", []) } + let fams = optional(st, "fonts", []) + if fams.len() == 0 { return [] } + let th = theme_named("" + t.theme) + let major = "" + optional(th, "major", "") + let minor = "" + optional(th, "minor", "") + if st.themes != nil { + for f in optional(st.themes, "font_schemes", []) { + if ("" + optional(f, "name", "")) == ("" + optional(t, "font_scheme", "")) { + major = "" + optional(f, "major", major) + minor = "" + optional(f, "minor", minor) + } + } + } + let out = [] + for name in [major, minor] { if name != "" && !has(fams, name) && !has(out, name) { out.push(name) } } + out +} +// After a design change, an undo or a redo: the deck's design again +// (deck.state), so the Theme segment follows without reopening. +fn refresh_state(t){ + if t == nil || t.doc == nil { return } + engine("state", {doc: t.doc}, fn(a){ + if !a.is_ok { return } + took_design(t, a.data) + let d = deck_by_path(t.path) + if d != nil && d.theme != t.theme { + d.theme = t.theme + remember(d) + } + if deck_tab() == t && t.editing == nil && t.seg == "theme" { render_inspector() } + }) +} // The deck's slides, sections and size again (deck.slides). fn refresh_slides(t, then){ engine("slides", {doc: t.doc}, fn(a){ @@ -1756,6 +1877,8 @@ fn detail_next(t){ t.detail = a.data t.detail_for = id t.sel = keep_shapes(t, t.sel) + t.chart_pending = {} + t.tb_pending = {} // The notes field of this slide keeps its typing: it isn't drawn again. let typing = t.seg == "notes" && optional(t, "notes_for", nil) == id if t.pending_edit != nil { @@ -1952,10 +2075,16 @@ fn undo(t, redo){ took_change(t, a.data) t.layouts_asked = false t.layout_pics = {} + // What the inspector shows comes back from the document: the slide's + // shapes (deck.slide) and the box's format (deck.format_state) through + // the pump, the deck's design here (deck.state). + t.chart_pending = {} + t.tb_pending = {} + refresh_state(t) // An undo can add, remove or move slides and shapes. refresh_slides(t, || { t.sel = keep_shapes(t, t.sel) - refresh_doc() + refresh_view(t) pump() }) }) @@ -2203,6 +2332,8 @@ fn canvas_double_tap(x, y){ fn selection_changed(t){ t.fmt = nil t.fmt_due = true + t.chart_pending = {} + t.tb_pending = {} t.paragraphs = nil t.grid = nil t.font_open = false @@ -2703,7 +2834,8 @@ fn design(t, args, what){ t.layouts_asked = false t.layout_pics = {} took_change(t, a.data) - refresh_doc() + refresh_view(t) + refresh_state(t) }) } fn set_background(t, kind){ @@ -2720,12 +2852,28 @@ fn background_all(t){ if t.bg_kind == "colour" { design(t, {background: {color: c[3] all: true}}, "change the backgrounds") return } design(t, {background: {gradient: {stops: [[0, c[6]], [1, c[1]]] angle: 90} all: true}}, "change the backgrounds") } +// A footer setting: shown at once, the deck's own after deck.state answers. +// Footer text shows the footer; an empty one hides it. fn footer(t, key, value){ + let p = optional(t, "footer_pending", {}) let f = {} - f[key] = value - if key == "slide_numbers" { t.slide_numbers = value } - if key == "date" { t.date = value } - if key == "footer_text" { t.footer_text = value } + if key == "slide_numbers" { + p.slide_numbers = value + f.slide_numbers = value + } + if key == "date" { + p.date = value + f.date = value + } + if key == "footer_text" { + let text = ("" + value).trim() + p.footer_text = text + p.footer = text != "" + f.footer_text = text + f.footer = text != "" + } + t.footer_pending = p + render_inspector() design(t, {footer: f}, "change the footer") } @@ -3333,6 +3481,24 @@ fn refresh_doc(){ if st.screen == "home" { render_tabs() return } show() } +// After a change that keeps the window's regions as they are (a design +// change, an undo): the canvas, the thumbnails, the inspector's content and +// the bars, drawn again without making their regions again, so the +// inspector keeps its scroll and the control just used stays in view. +fn refresh_view(t){ + if deck_tab() != t || t.state != "ready" || t.view != "slides" { + refresh_doc() + return + } + render_canvas() + render_thumbs() + render_pill() + render_insp_tabs() + render_inspector() + render_bar() + render_tabs() + render_status() +} // Which of the deck view's regions show. fn show_regions(){ let t = deck_tab() @@ -4102,7 +4268,7 @@ fn divider(){ SolidView{width: Fill height: 1 draw_bg.color: qd_line_soft} } // One segment of a segmented row: an icon or a word, tinted when chosen. fn seg_item(on, ic, text, act){ GestureView{width: Fill height: Fill cursor: MouseCursor.Hand on_tap: |x, y| act() - RoundedView{width: Fill height: Fill align: Align{x: 0.5 y: 0.5} show_bg: true + RoundedView{width: Fill height: Fill align: Align{x: 0.5 y: 0.5} show_bg: true selected: on draw_bg +: {color: pick(on, qd_tint, qd_field) border_size: 1.0 border_color: pick(on, qd_accent, qd_field) border_radius: 3.0} if ic != "" { Ico{width: 18 height: 18 draw_svg +: {svg: icon(ic) color: pick(on, qd_accent, qd_icon)}} } if ic == "" { Label{padding: 0 text: text draw_text +: {color: pick(on, qd_link, qd_ink2) text_style: theme.font_regular{font_size: 11.5}}} } @@ -4349,29 +4515,27 @@ fn font_list(t, current){ } } // Box: shrink to fit and vertical alignment. +// Box: shrink to fit and vertical alignment, as the box draws them +// (deck.format_state's autofit and anchor; a change shows at once through +// apply_format, then format_state answers again). fn box_section(t){ - let anchor = "" + optional(t, "box_anchor", "") - let shrink = optional(t, "box_shrink", nil) + let b = box_state(t.fmt) View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 12 bottom: 14} spacing: 8 heading("Box") - toggle_row("Shrink text when it overflows", shrink == true, |on| { - let x = deck_tab() - x.box_shrink = on - apply_format(x, {autofit: pick(on, "shrink", "none")}, "change the box") + toggle_row("Shrink text when it overflows", b.shrink, |on| { + apply_format(deck_tab(), {autofit: pick(on, "shrink", "none")}, "change the box") }) sublabel("Vertical alignment") SegRow{width: 216 - seg_item(anchor == "top", "valign-top", "", || box_anchor("top")) - seg_item(anchor == "middle", "valign-middle", "", || box_anchor("middle")) - seg_item(anchor == "bottom", "valign-bottom", "", || box_anchor("bottom")) + seg_item(b.anchor == "top", "valign-top", "", || box_anchor("top")) + seg_item(b.anchor == "middle", "valign-middle", "", || box_anchor("middle")) + seg_item(b.anchor == "bottom", "valign-bottom", "", || box_anchor("bottom")) } + if b.autofit == "resize" { hint("The box grows to fit its text.") } + if b.anchor == "justified" || b.anchor == "distributed" { hint("Its text is spread over the box's height.") } } } -fn box_anchor(a){ - let t = deck_tab() - t.box_anchor = a - apply_format(t, {anchor: a}, "align the box's text") -} +fn box_anchor(a){ apply_format(deck_tab(), {anchor: a}, "align the box's text") } // Shape: fill, outline, shadow. fn shape_section(t, sh){ let fill = hex_of(optional(sh, "fill", "")) @@ -4475,15 +4639,20 @@ fn table_section(t, sh){ let style = "" + optional(tb, "style", "") let r0 = min(t.cell[0], max(0, rows - 1)) let c0 = min(t.cell[1], max(0, cols - 1)) + let o = table_options(tb, optional(t, "tb_pending", {})) View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 12 bottom: 14} spacing: 8 heading("Table") sublabel("Style") View{width: Fill height: Fit flow: Flow.Right{wrap: true} spacing: 6 for s in TABLE_STYLES { chip(style == s.id, s.label, || table_op(deck_tab(), {op: "style" style: s.id}, "change the table's style")) } } - toggle_row("Header row", optional(tb, "header_row", optional(t, "tb_header", true)), |on| { let x = deck_tab() x.tb_header = on table_op(x, {op: "options" header_row: on}, "change the table") }) - toggle_row("Banded rows", optional(tb, "banded_rows", optional(t, "tb_banded", true)), |on| { let x = deck_tab() x.tb_banded = on table_op(x, {op: "options" banded_rows: on}, "change the table") }) - toggle_row("First column", optional(tb, "first_column", optional(t, "tb_first", false)), |on| { let x = deck_tab() x.tb_first = on table_op(x, {op: "options" first_column: on}, "change the table") }) + sublabel("Options") + toggle_row("Header row", o.header_row, |on| table_option(deck_tab(), "header_row", on)) + toggle_row("Total row", o.total_row, |on| table_option(deck_tab(), "total_row", on)) + toggle_row("Banded rows", o.banded_rows, |on| table_option(deck_tab(), "banded_rows", on)) + toggle_row("First column", o.first_column, |on| table_option(deck_tab(), "first_column", on)) + toggle_row("Last column", o.last_column, |on| table_option(deck_tab(), "last_column", on)) + toggle_row("Banded columns", o.banded_columns, |on| table_option(deck_tab(), "banded_columns", on)) sublabel("Cells") RoundedView{width: Fill height: Fit flow: Down show_bg: true draw_bg +: {color: qd_field border_size: 1.0 border_color: qd_field_line border_radius: 4.0} for r in min(rows, 10) { @@ -4519,6 +4688,38 @@ fn table_section(t, sh){ } } } +// A table option: shown at once, the table's own once deck.slide answers. +fn table_option(t, key, on){ + let p = optional(t, "tb_pending", {}) + let args = {op: "options"} + if key == "header_row" { + p.header_row = on + args.header_row = on + } + if key == "total_row" { + p.total_row = on + args.total_row = on + } + if key == "banded_rows" { + p.banded_rows = on + args.banded_rows = on + } + if key == "first_column" { + p.first_column = on + args.first_column = on + } + if key == "last_column" { + p.last_column = on + args.last_column = on + } + if key == "banded_columns" { + p.banded_columns = on + args.banded_columns = on + } + t.tb_pending = p + render_inspector() + table_op(t, args, "change the table") +} // A table cell's field: its text goes in when the person presses Return or // pauses; choosing it makes it the row and column the buttons act on. fn cell_field(t, r, c, text, on){ @@ -4537,14 +4738,8 @@ fn chart_section(t, sh){ let ch = optional(sh, "chart", {}) let kind = chart_id(optional(ch, "type", "column")) let g = chart_grid(t) - let flags = optional(t, "chart_flags", {}) - let title_on = optional(flags, "title", ("" + optional(ch, "title", "")) != "") - // The engine's defaults (model/src/chart.rs): a legend below, gridlines, - // no data labels; a key the service reports, even null, says otherwise. - let legend_on = optional(flags, "legend", pick(has_key(ch, "legend"), optional(ch, "legend", nil) != nil, true)) - let labels_on = optional(flags, "data_labels", pick(has_key(ch, "data_labels"), optional(ch, "data_labels", false) == true, false)) - let grid_on = optional(flags, "gridlines", pick(has_key(ch, "gridlines"), optional(ch, "gridlines", false) == true, true)) - let palette = "" + optional(flags, "palette", optional(ch, "palette", "colorful1")) + // What deck.slide reports of the chart, or a change on its way. + let cs = chart_state(ch, optional(t, "chart_pending", {})) let c = swatches_for(t) View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 12 bottom: 14} spacing: 8 heading("Chart") @@ -4567,40 +4762,56 @@ fn chart_section(t, sh){ } divider() heading("Elements") - toggle_row("Title", title_on, |on| chart_flag(deck_tab(), "title", on)) - toggle_row("Legend", legend_on, |on| chart_flag(deck_tab(), "legend", on)) - toggle_row("Data labels", labels_on, |on| chart_flag(deck_tab(), "data_labels", on)) - toggle_row("Gridlines", grid_on, |on| chart_flag(deck_tab(), "gridlines", on)) + toggle_row("Title", cs.title, |on| chart_flag(deck_tab(), "title", on)) + toggle_row("Legend", cs.legend, |on| chart_flag(deck_tab(), "legend", on)) + toggle_row("Data labels", cs.data_labels, |on| chart_flag(deck_tab(), "data_labels", on)) + toggle_row("Gridlines", cs.gridlines, |on| chart_flag(deck_tab(), "gridlines", on)) divider() heading("Colours") View{width: Fill height: Fit flow: Right spacing: 8 - palette_chip(palette.search("mono") < 0, [c[1], c[2], c[3], c[4]], || chart_flag(deck_tab(), "palette", "colorful1")) - palette_chip(palette.search("mono") >= 0, [c[1], c[1], c[1], c[1]], || chart_flag(deck_tab(), "palette", "monochrome1")) + palette_chip(!cs.mono, [c[1], c[2], c[3], c[4]], || chart_flag(deck_tab(), "palette", "colorful1")) + palette_chip(cs.mono, [c[1], c[1], c[1], c[1]], || chart_flag(deck_tab(), "palette", "monochrome1")) } } } fn palette_chip(on, colours, act){ GestureView{width: 74 height: 30 cursor: MouseCursor.Hand on_tap: |x, y| act() - RoundedView{width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} padding: 4 spacing: 0 show_bg: true + RoundedView{width: Fill height: Fill flow: Right align: Align{x: 0.5 y: 0.5} padding: 4 spacing: 0 show_bg: true selected: on draw_bg +: {color: qd_field border_size: pick(on, 2.0, 1.0) border_color: pick(on, qd_accent, qd_field_line) border_radius: 5.0} for i hex in colours { RoundedView{width: 16 height: 18 show_bg: true draw_bg +: {color: mix(hex_color(hex, #x888888), #xffffff, i * 0.18) border_radius: 1.0}} } } } } +// A chart element or palette: shown at once, the chart's own once deck.slide +// answers. An empty title takes the title away (SERVICE.md deck.chart). fn chart_flag(t, key, value){ - let flags = optional(t, "chart_flags", {}) - flags[key] = value - t.chart_flags = flags + let sh = selected_shape(t) + if sh == nil { return } + let p = optional(t, "chart_pending", {}) let args = {} if key == "title" { - let sh = selected_shape(t) let ch = optional(sh, "chart", {}) - args.title = pick(value, pick(("" + optional(ch, "title", "")) == "", "Chart Title", "" + optional(ch, "title", "")), nil) - } else if key == "legend" { - args.legend = pick(value, "b", "none") - } else { - args[key] = value + let had = "" + optional(ch, "title", "") + p.title = value + args.title = pick(value, pick(had == "", "Chart Title", had), "") + } + if key == "legend" { + p.legend = pick(value, "b", "none") + args.legend = p.legend + } + if key == "data_labels" { + p.data_labels = value + args.data_labels = value } + if key == "gridlines" { + p.gridlines = value + args.gridlines = value + } + if key == "palette" { + p.palette = value + args.palette = value + } + t.chart_pending = p render_inspector() chart_op(t, args, "change the chart") } @@ -4754,6 +4965,9 @@ fn theme_panel(t){ fonts = optional(st.themes, "font_schemes", []) } let wide = t.size[0] / max(1, t.size[1]) > 1.5 + // The deck's footer (deck.open, deck.state), or a change on its way. + let fs = footer_state(t.footer, optional(t, "footer_pending", {})) + let missing = missing_now(t) View{width: Fill height: Fit flow: Down View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 12 bottom: 14} spacing: 8 heading("Themes") @@ -4768,12 +4982,14 @@ fn theme_panel(t){ View{width: Fill height: Fit flow: Flow.Right{wrap: true} spacing: 6 for sc in schemes { scheme_card(t, sc) } } + if schemes.len() > 0 && !scheme_listed(schemes, current_colors(t)) { hint("This deck's own colours: " + current_colors(t) + ". Pick a set to use one of these instead.") } } divider() View{width: Fill height: Fit flow: Down padding: Inset{left: 16 right: 16 top: 16 bottom: 18} spacing: 6 heading("Fonts") - if optional(t, "missing_fonts", []).len() > 0 { hint("Drawn with similar fonts on this device: " + list_text(t.missing_fonts) + ".") } + if missing.len() > 0 { hint("Drawn with similar fonts on this device: " + list_text(missing) + ".") } for f in fonts { font_row(t, f) } + if fonts.len() > 0 && current_fonts(t) == "" && ("" + optional(t, "font_scheme", "")) != "" { hint("This deck's own fonts: " + t.font_scheme + ".") } } divider() View{width: Fill height: Fit flow: Right padding: Inset{left: 16 right: 16 top: 16 bottom: 24} spacing: 16 @@ -4786,9 +5002,9 @@ fn theme_panel(t){ } View{width: Fill height: Fit flow: Down spacing: 4 heading("Footer") - toggle_row("Slide numbers", optional(t, "slide_numbers", false), |on| footer(deck_tab(), "slide_numbers", on)) - toggle_row("Date", optional(t, "date", false), |on| footer(deck_tab(), "date", on)) - Field{height: 32 text: optional(t, "footer_text", "") empty_text: "Footer text" + toggle_row("Slide numbers", fs.slide_numbers, |on| footer(deck_tab(), "slide_numbers", on)) + toggle_row("Date", fs.date, |on| footer(deck_tab(), "date", on)) + Field{height: 32 text: fs.footer_text empty_text: "Footer text" on_return: |text| { let x = deck_tab() footer(x, "footer_text", text) }} } } @@ -4802,7 +5018,7 @@ fn theme_card(th, on, w, h, act){ let ink = hex_color(slot(c, 2, "#1e2b3c"), #x1e2b3c) let rule = hex_color(slot(c, 4, "#2e6fd8"), #x2e6fd8) GestureView{width: w + 4 height: Fit flow: Down align: Align{x: 0.5} spacing: 5 cursor: MouseCursor.Hand on_tap: |x, y| act() - RoundedView{width: w + 4 height: h + 4 padding: 2 flow: Overlay show_bg: true + RoundedView{width: w + 4 height: h + 4 padding: 2 flow: Overlay show_bg: true selected: on draw_bg +: {color: #x00000000 border_size: pick(on, 2.0, 0.0) border_color: qd_accent border_radius: 6.0} RoundedView{width: w height: h flow: Down align: Align{x: 0.5 y: 0.5} spacing: 3 show_bg: true draw_bg +: {color: ground border_size: 1.0 border_color: qd_thumb_line border_radius: 4.0} @@ -4820,9 +5036,9 @@ fn theme_card(th, on, w, h, act){ Label{padding: 0 text: theme_label(th.name) draw_text +: {color: pick(on, qd_link, qd_ink2) text_style: theme.font_regular{font_size: 11.5}}} } } -// A colour variant: its dark 2 and accents 1-4 as dots, its name below. The -// contract names the schemes; their colours come with the themes of the same -// name (SERVICE.md's deck.themes lists the others' names only). +// A colour set: its dark 2 and accents 1-4 as dots, its name below +// (deck.themes gives each set's twelve colours; an older service gave names +// only, and then a set shows its theme's colours, or its name alone). fn scheme_colors(sc){ let c = optional(sc, "colors", nil) if c != nil { return c } @@ -4833,33 +5049,22 @@ fn scheme_name(sc){ if n != nil { return "" + n } "" + sc } -// The deck's colours and fonts: what the person chose here, else the -// theme's own (deck.open names neither: a theme's colours are the scheme of -// its name, its fonts the pair with its major and minor families). -fn current_colors(t){ - let chosen = "" + optional(t, "colors", "") - if chosen != "" { return chosen } - "" + t.theme -} +// The deck's colour set and font pair (deck.open, deck.state), or one just +// picked and on its way. +fn current_colors(t){ scheme_mark(optional(t, "colors", ""), optional(t, "color_scheme", ""), t.theme) } fn current_fonts(t){ - let chosen = "" + optional(t, "fonts", "") - if chosen != "" { return chosen } let th = theme_named("" + t.theme) - if th == nil || st.themes == nil { return "" } - for f in optional(st.themes, "font_schemes", []) { - if ("" + optional(f, "major", "")) == ("" + optional(th, "major", "-")) && ("" + optional(f, "minor", "")) == ("" + optional(th, "minor", "-")) { return "" + optional(f, "name", "") } - } - "" + let pairs = [] + if st.themes != nil { pairs = optional(st.themes, "font_schemes", []) } + font_mark(optional(t, "fonts", ""), optional(t, "font_scheme", ""), pairs, optional(th, "major", ""), optional(th, "minor", "")) } fn scheme_card(t, sc){ let name = scheme_name(sc) let c = scheme_colors(sc) let on = current_colors(t) == name GestureView{width: floor((INSP_W - 32 - 12) / 3) height: 50 cursor: MouseCursor.Hand on_tap: |x, y| { let x2 = deck_tab() x2.colors = name design(x2, {colors: name}, "change the colours") } - RoundedView{width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 0.5} spacing: 5 show_bg: true + RoundedView{width: Fill height: Fill flow: Down align: Align{x: 0.5 y: 0.5} spacing: 5 show_bg: true selected: on draw_bg +: {color: qd_field border_size: pick(on, 2.0, 1.0) border_color: pick(on, qd_accent, qd_field_line) border_radius: 5.0} - // deck.themes names a scheme's colours only through the theme of its - // name: the others show their name alone. if c.len() >= 9 { View{width: Fit height: Fit flow: Right spacing: 4 for i in [2, 4, 5, 6, 7] { RoundedView{width: 13 height: 13 show_bg: true draw_bg +: {color: hex_color("" + c[i], #x888888) border_radius: 6.5}} } @@ -4869,6 +5074,10 @@ fn scheme_card(t, sc){ } } } +fn scheme_listed(schemes, name){ + for sc in schemes { if scheme_name(sc) == name { return true } } + false +} fn list_text(items){ let out = "" for i x in items { if i < 5 { out = out + pick(i == 0, "", ", ") + x } } @@ -4879,9 +5088,9 @@ fn font_row(t, f){ let name = "" + optional(f, "name", "") let on = current_fonts(t) == name GestureView{width: Fill height: 38 cursor: MouseCursor.Hand on_tap: |x, y| { let x2 = deck_tab() x2.fonts = name design(x2, {fonts: name}, "change the fonts") } - RoundedView{width: Fill height: Fill flow: Right align: Align{y: 0.5} padding: Inset{left: 12 right: 10} spacing: 8 show_bg: true + RoundedView{width: Fill height: Fill flow: Right align: Align{y: 0.5} padding: Inset{left: 12 right: 10} spacing: 8 show_bg: true selected: on draw_bg +: {color: qd_field border_size: pick(on, 2.0, 1.0) border_color: pick(on, qd_accent, qd_field_line) border_radius: 5.0} - Label{padding: 0 width: 118 text: name draw_text +: {color: pick(on, qd_link, qd_ink) text_style: theme.font_regular{font_size: 12.5}}} + Label{padding: 0 width: 118 max_lines: 1 text_overflow: TextOverflow.Ellipsis text: name draw_text +: {color: pick(on, qd_link, qd_ink) text_style: theme.font_regular{font_size: 12.5}}} Label{padding: 0 width: Fill max_lines: 1 text_overflow: TextOverflow.Ellipsis text: "" + optional(f, "major", "") + " / " + optional(f, "minor", "") draw_text +: {color: qd_muted text_style: theme.font_regular{font_size: 11}}} if on { Ico{width: 16 height: 16 draw_svg +: {svg: icon("check") color: qd_link}} } } diff --git a/apps/quickdeck/design/SERVICE.md b/apps/quickdeck/design/SERVICE.md index 6e1cb28e..c287ce61 100644 --- a/apps/quickdeck/design/SERVICE.md +++ b/apps/quickdeck/design/SERVICE.md @@ -434,3 +434,27 @@ changes; where they differ from the sections above, they win. whose content types come late (as LibreOffice writes them) opens. - **`deck.hit`** treats `selected: []` as no selection. - **Pictures** whose size can't be read are `invalid:`. + +## Inspector states (#507) + +The deck service also reports what the inspector shows, read from the open +document's model (read-only, no engine command): + +- `deck.themes`: `color_schemes` is `[{name, colors}]`, each scheme's twelve + `#rrggbb` colours in the engine's order (dk1, lt1, dk2, lt2, accent1 to + accent6, hlink, folHlink). +- `deck.open` and `deck.state`: `color_scheme` and `font_scheme` (the first + master's, whose theme `theme` names), and `footer` as + `{slide_numbers, footer, footer_text, date, hide_on_title}`, the keys + `deck.design {footer}` takes. `deck.state` gives `theme` too, so the + inspector follows an undo or a redo without reopening. +- `deck.format_state`: `anchor` (`top`, `middle` or `bottom`; an imported + deck may also say `justified` or `distributed`) and `autofit` (`none`, + `shrink` or `resize`), as the box draws them: its own setting, else its + layout's and master's, else the engine's default. +- `deck.slide`: a chart's `legend` (a position code, or `"none"`), + `data_labels`, `gridlines` and `palette`; a table's `options`, as + `{header_row, total_row, banded_rows, first_column, last_column, + banded_columns}`, the keys `deck.table {op: "options"}` takes. +- An Office document that isn't a presentation (a `.docx` or `.xlsx`) is + `unsupported:`. diff --git a/apps/quickdeck/design/map/02-edit-text.json b/apps/quickdeck/design/map/02-edit-text.json index 59dd6067..77e75a22 100644 --- a/apps/quickdeck/design/map/02-edit-text.json +++ b/apps/quickdeck/design/map/02-edit-text.json @@ -1867,7 +1867,7 @@ { "id": "box", "role": "Format · Box: shrink text when it overflows, vertical alignment", - "widget": "box_section: toggle_row 'Shrink text when it overflows' (QdToggle → deck.format autofit shrink/none); SegRow (216 wide) of seg_items valign-top/middle/bottom → deck.format anchor", + "widget": "box_section: toggle_row 'Shrink text when it overflows' (QdToggle → deck.format autofit shrink/none; on when deck.format_state says autofit shrink); SegRow (216 wide) of seg_items valign-top/middle/bottom → deck.format anchor (the one deck.format_state's anchor names is chosen; each segment says so through View.selected); a hint for a box that grows (autofit resize) or spreads its text (anchor justified, distributed)", "cite": [ "makepad widgets/src/view_ui.rs:805 (ScrollYView); a view keeps its scroll position across its own render(): view.rs:296-306", "makepad widgets/src/view_ui.rs:5 (View); flow: Overlay children placed by margin from the parent's origin (draw/src/turtle.rs)", @@ -1923,7 +1923,7 @@ "font": "theme.font_regular", "size": 11.5, "decision": null, - "notes": "Contract gap: deck.format_state reports no autofit or anchor, so the box's settings show off until the person sets them.", + "notes": "deck.format_state reports the box's anchor and autofit as the box draws them (SERVICE.md 'Inspector states (#507)'); a change shows at once and format_state's next answer confirms it, and after Undo or Redo the box's own come back.", "image_rect": [ 1194, 574, diff --git a/apps/quickdeck/design/map/03-chart.json b/apps/quickdeck/design/map/03-chart.json index 95ca92e7..14777a79 100644 --- a/apps/quickdeck/design/map/03-chart.json +++ b/apps/quickdeck/design/map/03-chart.json @@ -1530,7 +1530,7 @@ "font": "theme.font_regular", "size": 11.5, "decision": null, - "notes": "The images' five palettes draw only two ways in the engine (render/src/chart.rs:18-30: colourful cycles the accents, monochrome shades accent 1), so the build offers those two. 'Region' over the categories is sample text: the engine keeps no name for its categories. Contract gap: deck.slide's chart lists no legend, data labels or gridlines, so the toggles show the engine's defaults until set.", + "notes": "The images' five palettes draw only two ways in the engine (render/src/chart.rs:18-30: colourful cycles the accents, monochrome shades accent 1), so the build offers those two. 'Region' over the categories is sample text: the engine keeps no name for its categories. The toggles and the palette show deck.slide's chart (legend, data_labels, gridlines, palette, #507), a change at once until deck.slide answers again, and the chart's own after Undo or Redo.", "image_rect": [ 1187, 172, diff --git a/apps/quickdeck/design/map/05-theme.json b/apps/quickdeck/design/map/05-theme.json index ff0f2296..b08935f7 100644 --- a/apps/quickdeck/design/map/05-theme.json +++ b/apps/quickdeck/design/map/05-theme.json @@ -1455,7 +1455,7 @@ { "id": "colours", "role": "Theme · Colours: colour variants, five dots each", - "widget": "scheme_card per color_schemes entry (100 x 50): RoundedView with five RoundedView dots 13 (dark 2, accents 1-4) and the name 10.5 → deck.design colors; the current one outlined: the person's choice, else the scheme of the theme's name", + "widget": "scheme_card per color_schemes entry (100 x 50): RoundedView with five RoundedView dots 13 (dark 2, accents 1-4, from the set's twelve colours) and the name 10.5 → deck.design colors; the deck's set outlined (deck.open and deck.state color_scheme; View.selected says which); a hint names a deck's own set when it isn't one of these (an imported deck's)", "cite": [ "makepad widgets/src/view_ui.rs:805 (ScrollYView); a view keeps its scroll position across its own render(): view.rs:296-306", "makepad widgets/src/view_ui.rs:5 (View); flow: Overlay children placed by margin from the parent's origin (draw/src/turtle.rs)", @@ -1507,7 +1507,7 @@ "font": "theme.font_regular", "size": 11.5, "decision": null, - "notes": "The engine has thirteen colour schemes (the eight themes' and Grayscale, Ocean, Sunset, Forest, Berry); the image shows five, so the cards are three to a row to keep Fonts in reach. Contract gap: deck.themes' color_schemes are names only (deckcraft engine/src/cmd/design.rs:92), so the five schemes no theme is named after draw no dots (requested: color_schemes as {name, colors}). Contract gap: deck.open names no colour or font scheme, so the current one is inferred from the theme until the person picks one.", + "notes": "The engine has thirteen colour sets (the eight themes' and Grayscale, Ocean, Sunset, Forest, Berry); the image shows five, so the cards are three to a row to keep Fonts in reach. deck.themes gives every set's twelve colours, and deck.open and deck.state name the deck's (#507), so the mark follows a change, an undo and a redo.", "image_rect": [ 1161, 437, @@ -1688,7 +1688,7 @@ { "id": "fonts", "role": "Theme · Fonts: font pairs, the major and minor families", - "widget": "font_row per font_schemes entry: RoundedView (2 pt accent when chosen) with Label name 12.5 and Label 'Merriweather / Open Sans' 11, Svg check → deck.design fonts; the current one is the person's choice, else the pair with the theme's major and minor families; a hint names the families drawn with similar fonts (deck.open missing_fonts)", + "widget": "font_row per font_schemes entry: RoundedView (2 pt accent when chosen) with Label name 12.5 and Label 'Merriweather / Open Sans' 11, Svg check → deck.design fonts; the deck's pair marked (deck.open and deck.state font_scheme by name, else the pair with the theme's families: a built-in theme's own pair is named after the theme); a hint names the families drawn with similar fonts (deck.open missing_fonts), and a deck's own pair when it isn't one of these", "cite": [ "makepad widgets/src/view_ui.rs:805 (ScrollYView); a view keeps its scroll position across its own render(): view.rs:296-306", "makepad widgets/src/view_ui.rs:5 (View); flow: Overlay children placed by margin from the parent's origin (draw/src/turtle.rs)", @@ -1903,7 +1903,7 @@ { "id": "size_footer", "role": "Theme · Slide size (16:9, 4:3) and footer (slide numbers, date)", - "widget": "SegRow 16:9 / 4:3 → deck.design size; toggle_rows Slide numbers, Date and a Field 'Footer text' → deck.design footer", + "widget": "SegRow 16:9 / 4:3 → deck.design size; toggle_rows Slide numbers, Date and a Field 'Footer text' (typing it shows the footer) → deck.design footer, showing deck.open's and deck.state's footer", "cite": [ "makepad widgets/src/view_ui.rs:805 (ScrollYView); a view keeps its scroll position across its own render(): view.rs:296-306", "makepad widgets/src/view_ui.rs:5 (View); flow: Overlay children placed by margin from the parent's origin (draw/src/turtle.rs)", @@ -1950,7 +1950,7 @@ "font": "theme.font_regular", "size": 11.5, "decision": null, - "notes": "Contract gap: the footer's state isn't reported, so the toggles show what the person set in this session.", + "notes": "The footer controls show the deck's footer (#507: slide_numbers, footer, footer_text, date); a change shows at once, deck.state's answer replaces it, and an undo or a redo brings the deck's own back.", "image_rect": [ 1161, 828, diff --git a/apps/quickdeck/dev-fixture/engine.splash b/apps/quickdeck/dev-fixture/engine.splash index a3c519b2..81003cb1 100644 --- a/apps/quickdeck/dev-fixture/engine.splash +++ b/apps/quickdeck/dev-fixture/engine.splash @@ -6,7 +6,8 @@ // dev/covers, dev/layouts): the approved designs' sample deck drawn with PIL, // standing in for the engine's renders. It answers SERVICE.md's methods with // SERVICE.md's shapes, keeps each open document in memory (edits change its -// data, not its pictures), and answers every call on a later turn. +// data, not its pictures; undo and redo step back through it), and answers +// every call on a later turn. let fixture_data = nil let fixture_docs = {} let fixture_next = 1 @@ -112,6 +113,7 @@ fn fx_open_deck(path){ let deck = optional(decks, path, nil) if deck == nil { return nil } deck = fx_copy(deck) + fx_design_defaults(deck) let names = [] for sec in optional(deck, "sections", []) { names.push(sec.name) } if names.len() == 0 { names.push("") } @@ -123,6 +125,23 @@ fn fx_open_deck(path){ } deck } +// The deck's design as the service reports it (SERVICE.md "Inspector states +// (#507)"): a built-in theme's colour set and font pair are named after it. +fn fx_design_defaults(deck){ + if optional(deck, "color_scheme", nil) == nil { deck.color_scheme = deck.theme } + if optional(deck, "font_scheme", nil) == nil { deck.font_scheme = deck.theme } + if optional(deck, "footer", nil) == nil { deck.footer = {slide_numbers: false footer: false footer_text: "" date: false hide_on_title: false} } +} +fn fx_design_state(d){ {theme: d.deck.theme color_scheme: d.deck.color_scheme font_scheme: d.deck.font_scheme footer: d.deck.footer} } +// One undo step per change, as the service folds them: the deck before it. +let FX_CHANGES = ["format", "set_text", "notes", "insert", "shape", "picture", "table", "chart", "transition", "design", "slides_op"] +fn fx_remember(d){ + let past = optional(d, "past", []) + past.push(fx_copy(d.deck)) + while past.len() > 20 { past.remove(0) } + d.past = past + d.future = [] +} fn fx_new_handle(path, deck){ fixture_next = fixture_next + 1 let h = "d" + fixture_next @@ -134,7 +153,8 @@ fn fx_open_answer(h, d){ let layouts = [] for l in optional(fixture(), "layouts", []) { layouts.push({id: l.id name: l.name kind: l.kind}) } {doc: h path: d.path title: d.deck.title size: d.deck.size slides: d.deck.slides.len() theme: d.deck.theme layouts: layouts - sections: fx_sections(d) rev: d.rev edited: false can_undo: false can_redo: false missing_fonts: optional(d.deck, "missing_fonts", [])} + sections: fx_sections(d) rev: d.rev edited: false can_undo: false can_redo: false missing_fonts: optional(d.deck, "missing_fonts", []) + color_scheme: d.deck.color_scheme font_scheme: d.deck.font_scheme footer: d.deck.footer} } fn fx_refusal(path){ optional(optional(fixture(), "refusals", {}), path, nil) } fn fx_new_id(){ @@ -143,7 +163,8 @@ fn fx_new_id(){ } fn fx_text_shape(id, box, text, size){ {id: id name: "Text " + id kind: "textBox" box: box rotation: 0 text: text paragraphs: [{text: text level: 0}] - fmt: {font: "Open Sans" size: size bold: false italic: false underline: false strike: false color: "#2B3542" align: "l" bullets: false numbering: false line_spacing: 1.0}} + fmt: {font: "Open Sans" size: size bold: false italic: false underline: false strike: false color: "#2B3542" align: "l" bullets: false numbering: false line_spacing: 1.0 + anchor: "top" autofit: "none"}} } fn fx_blank_slide(layout, title){ let shapes = [fx_text_shape(fx_new_id(), [60, 36, 840, 58], title, 34)] @@ -188,6 +209,7 @@ fn fixture_answer(method, args){ } if slides.len() == 0 { slides.push(fx_blank_slide("Title Slide", "")) } let deck = {key: base.key title: file_stem(base_name(out)) theme: optional(args, "theme", "Harbor") size: [960, 540] slides: slides sections: [] section_names: [""] missing_fonts: []} + fx_design_defaults(deck) fs.write(out, "Quick Deck dev fixture: a stand-in deck\n") let n = fx_new_handle(out, deck) return fx_ok(fx_open_answer(n.handle, n.doc)) @@ -203,7 +225,8 @@ fn fixture_answer(method, args){ fixture_docs = kept return fx_ok({closed: true}) } - if method == "state" { return fx_ok({rev: d.rev edited: d.edited can_undo: d.can_undo can_redo: d.can_redo undo: nil redo: nil slides: d.deck.slides.len()}) } + if method == "state" { return fx_ok(fx_with({rev: d.rev edited: d.edited can_undo: d.can_undo can_redo: d.can_redo undo: nil redo: nil slides: d.deck.slides.len()}, fx_design_state(d))) } + if has(FX_CHANGES, method) { fx_remember(d) } if method == "save" { let path = optional(args, "path", d.path) if path != d.path { @@ -251,7 +274,7 @@ fn fixture_answer(method, args){ let sh = nil if s != nil { sh = fx_shape(s, fx_arg(args, "id")) } if sh == nil { return fx_err("unknown_id: no such shape") } - return fx_ok(optional(sh, "fmt", {})) + return fx_ok(fx_with({anchor: "top" autofit: "none"}, optional(sh, "fmt", {}))) } if method == "format" { if s == nil { return fx_err("unknown_id: no such slide") } @@ -295,7 +318,8 @@ fn fixture_answer(method, args){ for c in args.cols { row.push("") } cells.push(row) } - sh = {id: id name: "Table " + id kind: "table" box: [96, 150, 768, args.rows * 40] rotation: 0 table: {rows: args.rows cols: args.cols style: "medium2-accent1" cells: cells}} + sh = {id: id name: "Table " + id kind: "table" box: [96, 150, 768, args.rows * 40] rotation: 0 table: {rows: args.rows cols: args.cols style: "medium2-accent1" cells: cells + options: {header_row: true total_row: false banded_rows: true first_column: false last_column: false banded_columns: false}}} } if kind == "chart" { let cats = optional(args, "categories", ["Q1", "Q2", "Q3", "Q4"]) @@ -352,10 +376,25 @@ fn fixture_answer(method, args){ if optional(args, "type", nil) != nil { c.type = args.type } if optional(args, "categories", nil) != nil { c.categories = args.categories } if optional(args, "series", nil) != nil { c.series = args.series } + if has_key(args, "title") { c.title = "" + optional(args, "title", "") } + if optional(args, "legend", nil) != nil { c.legend = "" + args.legend } + if optional(args, "data_labels", nil) != nil { c.data_labels = args.data_labels == true } + if optional(args, "gridlines", nil) != nil { c.gridlines = args.gridlines == true } + if optional(args, "palette", nil) != nil { c.palette = "" + args.palette } } if method == "table" { let tb = sh.table if args.op == "style" { tb.style = args.style } + if args.op == "options" { + let o = optional(tb, "options", {header_row: true total_row: false banded_rows: true first_column: false last_column: false banded_columns: false}) + if optional(args, "header_row", nil) != nil { o.header_row = args.header_row == true } + if optional(args, "total_row", nil) != nil { o.total_row = args.total_row == true } + if optional(args, "banded_rows", nil) != nil { o.banded_rows = args.banded_rows == true } + if optional(args, "first_column", nil) != nil { o.first_column = args.first_column == true } + if optional(args, "last_column", nil) != nil { o.last_column = args.last_column == true } + if optional(args, "banded_columns", nil) != nil { o.banded_columns = args.banded_columns == true } + tb.options = o + } if args.op == "insert_row" || args.op == "delete_row" { let rows = [] let at = args.row + pick(optional(args, "below", false) == true, 1, 0) @@ -384,18 +423,48 @@ fn fixture_answer(method, args){ return fx_ok(fx_change(d, targets)) } if method == "design" { - if optional(args, "theme", nil) != nil { d.deck.theme = args.theme } + if optional(args, "theme", nil) != nil { + d.deck.theme = args.theme + d.deck.color_scheme = args.theme + d.deck.font_scheme = args.theme + } + if optional(args, "colors", nil) != nil { d.deck.color_scheme = "" + args.colors } + if optional(args, "fonts", nil) != nil { d.deck.font_scheme = "" + args.fonts } + let f = optional(args, "footer", nil) + if f != nil { + let now = d.deck.footer + if optional(f, "slide_numbers", nil) != nil { now.slide_numbers = f.slide_numbers == true } + if optional(f, "date", nil) != nil { now.date = f.date == true } + if optional(f, "footer", nil) != nil { now.footer = f.footer == true } + if optional(f, "footer_text", nil) != nil { now.footer_text = "" + f.footer_text } + if optional(f, "hide_on_title", nil) != nil { now.hide_on_title = f.hide_on_title == true } + d.deck.footer = now + } if optional(args, "size", nil) == "4:3" { d.deck.size = [720, 540] } if optional(args, "size", nil) == "16:9" { d.deck.size = [960, 540] } return fx_ok(fx_with(fx_change(d, fx_all(d)), {size: d.deck.size})) } if method == "slides_op" { return fx_slides_op(d, args) } if method == "undo" || method == "redo" { - if method == "undo" && !d.can_undo { return fx_err("invalid: nothing to undo") } - if method == "redo" && !d.can_redo { return fx_err("invalid: nothing to redo") } + let past = optional(d, "past", []) + let future = optional(d, "future", []) + if method == "undo" && past.len() == 0 { return fx_err("invalid: nothing to undo") } + if method == "redo" && future.len() == 0 { return fx_err("invalid: nothing to redo") } + if method == "undo" { + future.push(fx_copy(d.deck)) + d.deck = past[past.len() - 1] + past.remove(past.len() - 1) + } else { + past.push(fx_copy(d.deck)) + d.deck = future[future.len() - 1] + future.remove(future.len() - 1) + } + d.past = past + d.future = future d.rev = d.rev + 1 - d.can_undo = method == "redo" - d.can_redo = method == "undo" + d.edited = true + d.can_undo = past.len() > 0 + d.can_redo = future.len() > 0 return fx_ok({rev: d.rev changed: fx_all(d) edited: true can_undo: d.can_undo can_redo: d.can_redo undo: nil redo: nil}) } if method == "show" { diff --git a/apps/quickdeck/dev-fixture/make_fixture.py b/apps/quickdeck/dev-fixture/make_fixture.py index 58ae4a09..86734440 100644 --- a/apps/quickdeck/dev-fixture/make_fixture.py +++ b/apps/quickdeck/dev-fixture/make_fixture.py @@ -68,9 +68,7 @@ def rgb(h): ("Coral Reef", ["0F2A33", "FFFFFF", "0E4D64", "FDF1EC", "FF6F59", "254441", "43AA8B", "B2B09B", "EF3054", "3F88C5", "3F88C5", "EF3054"], "Poppins", "Nunito Sans"), ("Slate", ["111827", "FFFFFF", "374151", "F3F4F6", "6366F1", "0EA5E9", "10B981", "F59E0B", "EF4444", "8B5CF6", "6366F1", "8B5CF6"], "Roboto", "Roboto"), ] -# The engine's other colour schemes (deckcraft's catalog). The service lists -# them by name only, so the fixture does too; their colours are kept here for -# when SERVICE.md gives a scheme's colours. +# The engine's other colour schemes (deckcraft's builtin_color_schemes). EXTRA_SCHEMES = [ ("Grayscale", ["000000", "FFFFFF", "000000", "F8F8F8", "DDDDDD", "B2B2B2", "969696", "808080", "5F5F5F", "4D4D4D", "5F5F5F", "919191"]), ("Ocean", ["000000", "FFFFFF", "13293D", "E8F1F2", "006494", "247BA0", "1B98E0", "6CC1E8", "13293D", "0B3954", "1B98E0", "6CC1E8"]), @@ -155,12 +153,14 @@ def save(self, path, size=None): # ---- the Q3 2026 board update (designs 02-10) ---------------------------------- -def tbox(i, name, box, text, kind="placeholder", ph="body", size=18, bold=False, align="l", bullets=False, paragraphs=None, color=INK, font="Open Sans"): +def tbox(i, name, box, text, kind="placeholder", ph="body", size=18, bold=False, align="l", bullets=False, paragraphs=None, color=INK, font="Open Sans", + anchor="top", autofit="none"): paras = paragraphs if paragraphs is not None else [{"text": t, "level": 0} for t in text.split("\n")] return {"id": i, "name": name, "kind": kind, "box": box, "rotation": 0, "placeholder": ph if kind == "placeholder" else None, "text": text, "paragraphs": paras, + # deck.format_state's answer, with the box's anchor and autofit (#507). "fmt": {"font": font, "size": size, "bold": bold, "italic": False, "underline": False, "strike": False, "color": color, - "align": align, "bullets": bullets, "numbering": False, "line_spacing": 1.2}} + "align": align, "bullets": bullets, "numbering": False, "line_spacing": 1.2, "anchor": anchor, "autofit": autofit}} def content_shapes(n, title, extra): @@ -259,7 +259,7 @@ def s4(sl): sl.footer(4) chart4 = {"id": 403, "name": "Chart 3", "kind": "chart", "box": [96, 120, 768, 360], "rotation": 0, "chart": {"type": "Clustered Column", "title": "Revenue by region, Q3 2026 (USD millions)", "categories": [r[0] for r in REGIONS], - "series": [{"name": "Q3 2026", "values": [r[1] for r in REGIONS]}], "legend": None, "data_labels": True, "gridlines": True, "palette": "colorful1"}} + "series": [{"name": "Q3 2026", "values": [r[1] for r in REGIONS]}], "legend": "none", "data_labels": True, "gridlines": True, "palette": "colorful1"}} add("Revenue by region", "Title and Content", content_shapes(4, "Revenue by region", [chart4]), s4, "push") rev_lines = ["Northwest led", "with $12.4 million", "Prices held steady", "in every region", "Online orders", "rose 18%"] @@ -281,10 +281,10 @@ def s5(sl, text=True): sl.footer(5) add("Revenue grew 12% year over year", "Two Content", content_shapes(5, "Revenue grew 12% year over year", [ tbox(503, "Content 2", [60, 132, 400, 300], "\n".join(rev_paras), bullets=True, size=20, - paragraphs=[{"text": t, "level": 0} for t in rev_paras]), + paragraphs=[{"text": t, "level": 0} for t in rev_paras], autofit="shrink"), {"id": 504, "name": "Chart 3", "kind": "chart", "box": [480, 120, 420, 330], "rotation": 0, "chart": {"type": "Line with Markers", "title": "Quarterly revenue (USD millions)", "categories": [q[0] for q in QUARTERS], - "series": [{"name": "Revenue", "values": [q[1] for q in QUARTERS]}], "legend": None, "data_labels": True, "gridlines": True, "palette": "colorful1"}}]), + "series": [{"name": "Revenue", "values": [q[1] for q in QUARTERS]}], "legend": "none", "data_labels": True, "gridlines": True, "palette": "colorful1"}}]), lambda sl: s5(sl, True), "fade", "", editing_draw=lambda sl: s5(sl, False)) def s6(sl): @@ -309,7 +309,10 @@ def s6(sl): sl.footer(6) add("Membership by region", "Title and Content", content_shapes(6, "Membership by region", [ {"id": 603, "name": "Table 3", "kind": "table", "box": [112, 128, 768, 312], "rotation": 0, - "table": {"rows": len(MEMBERS), "cols": 3, "style": "medium2-accent1", "cells": [list(r) for r in MEMBERS]}}]), s6, "fade", + "table": {"rows": len(MEMBERS), "cols": 3, "style": "medium2-accent1", "cells": [list(r) for r in MEMBERS], + # design 10's table: a header row, banded rows and a total row (#507's options) + "options": {"header_row": True, "total_row": True, "banded_rows": True, "first_column": False, "last_column": False, + "banded_columns": False}}}]), s6, "fade", "Total membership passed 18,000 for the first time. Northwest added 460 members, most through the referral program. " "Ask Jun Park to say a word about the Metro waitlist.") @@ -459,10 +462,9 @@ def main(): "themes": { "themes": [{"name": n, "label": "Default" if n == "DeckCraft" else n, "colors": ["#" + c for c in cols], "major": mj, "minor": mn} for n, cols, mj, mn in THEMES], - # As the service answers (deck.themes, from the engine's design.themes): - # the colour schemes by name only, so the five no theme is named after - # show no colours (SERVICE.md lists them as [name]). - "color_schemes": [n for n, _, _, _ in THEMES] + [n for n, _ in EXTRA_SCHEMES], + # As the service answers (#507): every colour set with its twelve colours. + "color_schemes": [{"name": n, "colors": ["#" + c for c in cols]} for n, cols, _, _ in THEMES] + + [{"name": n, "colors": ["#" + c for c in cols]} for n, cols in EXTRA_SCHEMES], "font_schemes": [{"name": n, "major": a, "minor": b} for n, a, b in FONT_SCHEMES], }, "fonts": FAMILIES, @@ -512,7 +514,10 @@ def main(): "transition": s["transition"], "builds": 1 if n == 2 else 0, "shapes": shapes, "pic": n}) fixture["decks"][path] = {"key": key, "title": title, "theme": theme, "size": [960, 540], "slides": deck_slides, "sections": [{"name": "Highlights", "first": 0, "count": 7}, {"name": "Outlook", "first": 7, "count": 5}], - "missing_fonts": ["Merriweather", "Open Sans"]} + "missing_fonts": ["Merriweather", "Open Sans"], + # design 05: slide numbers on, no date; the slides' footer text + "footer": {"slide_numbers": True, "footer": True, "footer_text": "Northwind Cooperative", "date": False, + "hide_on_title": False}} else: lines, sub, ground, ink, accent = COVERS[title] sl = cover_title_slide(lines, sub, ground, ink, accent) diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/README.md b/apps/quickdeck/tests/evidence/inspector-states-20261011/README.md new file mode 100644 index 00000000..43d9d25f --- /dev/null +++ b/apps/quickdeck/tests/evidence/inspector-states-20261011/README.md @@ -0,0 +1,60 @@ +# Quick Deck's inspector states — 2026-10-11 + +English | [简体中文](README.zh-CN.md) + +The deck service now reports what the inspector shows (#507, SERVICE.md +"Inspector states"), and Quick Deck shows it: a box's anchor and autofit, a +chart's legend, data labels, gridlines and palette, a table's options, every +colour set's colours, the deck's colour set, font pair and footer. After an +Undo or a Redo the controls show the deck's own again, without reopening. +[ui.py](../../ui.py) ran it on this branch, and +[receipt.json](receipt.json) holds the digests, the commands, what each run +checked, the rounds that failed before these and what was not verified. + +**App Hub's `card-host`** (macOS 26.6.2, Apple silicon, at the runtime's +64 ms budget), on the dev fixture, which answers as the service does: + +- [fixture/](fixture): the designs beside the app for the screens that + change, light, then dark (`d`-prefixed; only 10 has a dark design): 02 + (the box's shrink switch on and its top alignment chosen, as the design + draws them), 03 (the chart's title, data labels and gridlines on, its + legend off, its colourful palette) and 05 (Harbor's colour set and Classic's + font pair marked; every colour set with its colours). +- [before-after/](before-after): the app on main (left) beside this branch + (right), on the same fixture, for 02, 03, 05, the table's options (11) and + the footer (12), light and dark. On main, the box's switches show off, the + chart's legend shows on although the chart has none, the table shows three + of its six options at their defaults, the footer's slide numbers show off + and its text is empty, and the colour set and font pair are guessed from + the theme. +- The runs also checked each switch and mark against the stand-in, and a + table option and the footer's date against it after a change and its + Undo (their frames are not kept: they look like 11 and 12). + +**A hidden desktop shell with the real `deck` engine** (Ubuntu 26.04 LTS, +x86_64, 72 CPUs, no GPU; headless weston, as before): the shell was built +from this branch's tree on main eb2fd5d1, which has the service of #503 and +#507. Each frame is the app's window, 1292 x 662 points, at one pixel per +point. In the journey's light pass, each state was read, changed, read back +from the engine (the shape chosen again, or the deck's design asked again) +and undone, and read back again ([linux/states.json](linux/states.json) +records what the engine first reported): + +- [linux/10-box.png](linux/10-box.png), [10b-box-undone.png](linux/10b-box-undone.png): + the Agenda box, which the engine draws at the top and shrinking its text + to fit, set to the bottom with shrink off (the engine moves its text + down), then both undone. +- [11-chart.png](linux/11-chart.png), [11b-chart-undone.png](linux/11b-chart-undone.png): + a new chart's data labels on (the engine draws the values over the bars), + then undone. +- [12-table.png](linux/12-table.png), [12b-table-undone.png](linux/12b-table-undone.png): + a new table's total row on, then undone. +- [13-colours.png](linux/13-colours.png), [13b-colours-undone.png](linux/13b-colours-undone.png): + the Ocean colour set, then undone: Ember's own set is marked again. +- [14-footer.png](linux/14-footer.png), [14b-footer-undone.png](linux/14b-footer-undone.png): + slide numbers on (the slide shows its number), then undone. + +The rest of the journey (dark, restart) passed as in +[v2-20261011](../v2-20261011/README.md). + +Every frame was inspected. diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/README.zh-CN.md b/apps/quickdeck/tests/evidence/inspector-states-20261011/README.zh-CN.md new file mode 100644 index 00000000..f43da327 --- /dev/null +++ b/apps/quickdeck/tests/evidence/inspector-states-20261011/README.zh-CN.md @@ -0,0 +1,23 @@ +# Quick Deck 检查器状态 — 2026-10-11 + +[English](README.md) | 简体中文 + +deck 服务现在会报告检查器要显示的内容(#507,SERVICE.md 的“Inspector states”),Quick Deck 也照此显示:文本框的垂直对齐和自动调整,图表的图例、数据标签、网格线和配色,表格的选项,每组配色方案的颜色,以及演示文稿的配色方案、字体搭配和页脚。撤销或重做之后,无需重新打开,各控件就会再次显示演示文稿自己的设置。[ui.py](../../ui.py) 在本分支上运行了这些检查,[receipt.json](receipt.json) 记录了摘要、命令、每次运行检查的内容、此前失败的各轮次以及未验证的内容。 + +**App Hub 的 `card-host`**(macOS 26.6.2,Apple 芯片,运行时默认的 64 ms 预算),使用像服务一样应答的开发夹具: + +- [fixture/](fixture):发生变化的页面与设计图并排,先浅色后深色(文件名以 `d` 开头;只有 10 有深色设计图):02(文本框的“溢出时缩小文字”开关打开、顶端对齐被选中,与设计图一致)、03(图表的标题、数据标签和网格线打开,图例关闭,彩色配色)和 05(标记了 Harbor 配色方案和 Classic 字体搭配;每组配色方案都显示其颜色)。 +- [before-after/](before-after):main 上的应用(左)与本分支(右)并排,使用同一个夹具,覆盖 02、03、05、表格的选项(11)和页脚(12),浅色与深色。在 main 上,文本框的开关显示为关闭,图表没有图例却显示图例打开,表格只显示六个选项中的三个且都是默认值,页脚的幻灯片编号显示为关闭、文字为空,配色方案和字体搭配由主题推测。 +- 这些运行还把每个开关和标记与替身核对,并在改动及撤销后核对一个表格选项和页脚的日期(这些截图没有保留:它们与 11 和 12 相同)。 + +**隐藏运行、使用真实 `deck` 引擎的桌面 Shell**(Ubuntu 26.04 LTS,x86_64,72 个 CPU,没有 GPU;与之前一样使用无头 weston):Shell 由基于 main eb2fd5d1 的本分支构建,main 已包含 #503 和 #507 的服务。每张截图都是应用窗口,1292 x 662 点,一像素对应一点。在流程的浅色部分,每个状态都先读取,再改动,然后从引擎读回(重新选中形状,或重新查询演示文稿的设计),再撤销并再次读回([linux/states.json](linux/states.json) 记录了引擎最初报告的内容): + +- [linux/10-box.png](linux/10-box.png)、[10b-box-undone.png](linux/10b-box-undone.png):Agenda 文本框,引擎把它画在顶端并缩小文字以适应;改为底端对齐并关闭缩小(引擎把文字移到下方),然后两项都撤销。 +- [11-chart.png](linux/11-chart.png)、[11b-chart-undone.png](linux/11b-chart-undone.png):新图表的数据标签打开(引擎在柱子上方画出数值),然后撤销。 +- [12-table.png](linux/12-table.png)、[12b-table-undone.png](linux/12b-table-undone.png):新表格的汇总行打开,然后撤销。 +- [13-colours.png](linux/13-colours.png)、[13b-colours-undone.png](linux/13b-colours-undone.png):选择 Ocean 配色方案,然后撤销:Ember 自己的配色方案再次被标记。 +- [14-footer.png](linux/14-footer.png)、[14b-footer-undone.png](linux/14b-footer-undone.png):打开幻灯片编号(幻灯片显示其编号),然后撤销。 + +流程的其余部分(深色流程、重启)与 [v2-20261011](../v2-20261011/README.zh-CN.md) 一样通过。 + +每张截图都经过检查。 diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/02-edit-text.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/02-edit-text.png new file mode 100644 index 00000000..fea47e8a Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/02-edit-text.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/03-chart.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/03-chart.png new file mode 100644 index 00000000..8c194042 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/03-chart.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/05-theme.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/05-theme.png new file mode 100644 index 00000000..03093027 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/05-theme.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/11-table.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/11-table.png new file mode 100644 index 00000000..b40effb5 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/11-table.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/12-footer.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/12-footer.png new file mode 100644 index 00000000..221aa1d8 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/12-footer.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d02-edit-text.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d02-edit-text.png new file mode 100644 index 00000000..a2914674 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d02-edit-text.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d03-chart.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d03-chart.png new file mode 100644 index 00000000..ccd92c90 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d03-chart.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d05-theme.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d05-theme.png new file mode 100644 index 00000000..4b01ec19 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d05-theme.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d11-table.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d11-table.png new file mode 100644 index 00000000..6719ddba Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d11-table.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d12-footer.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d12-footer.png new file mode 100644 index 00000000..83b44ac4 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/before-after/d12-footer.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/02-edit-text.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/02-edit-text.png new file mode 100644 index 00000000..e0df7526 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/02-edit-text.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/03-chart.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/03-chart.png new file mode 100644 index 00000000..fc7321d5 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/03-chart.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/05-theme.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/05-theme.png new file mode 100644 index 00000000..1ef73747 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/05-theme.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d02-edit-text.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d02-edit-text.png new file mode 100644 index 00000000..f1cb33ad Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d02-edit-text.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d03-chart.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d03-chart.png new file mode 100644 index 00000000..130770f5 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d03-chart.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d05-theme.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d05-theme.png new file mode 100644 index 00000000..1120b49c Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/fixture/d05-theme.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/10-box.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/10-box.png new file mode 100644 index 00000000..bea6909a Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/10-box.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/10b-box-undone.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/10b-box-undone.png new file mode 100644 index 00000000..003e4b51 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/10b-box-undone.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/11-chart.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/11-chart.png new file mode 100644 index 00000000..49433426 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/11-chart.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/11b-chart-undone.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/11b-chart-undone.png new file mode 100644 index 00000000..e5f979c2 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/11b-chart-undone.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/12-table.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/12-table.png new file mode 100644 index 00000000..1c694aa1 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/12-table.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/12b-table-undone.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/12b-table-undone.png new file mode 100644 index 00000000..339897fe Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/12b-table-undone.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/13-colours.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/13-colours.png new file mode 100644 index 00000000..a99a1f29 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/13-colours.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/13b-colours-undone.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/13b-colours-undone.png new file mode 100644 index 00000000..cfade519 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/13b-colours-undone.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/14-footer.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/14-footer.png new file mode 100644 index 00000000..365c5884 Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/14-footer.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/14b-footer-undone.png b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/14b-footer-undone.png new file mode 100644 index 00000000..cbc7ef5a Binary files /dev/null and b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/14b-footer-undone.png differ diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/states.json b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/states.json new file mode 100644 index 00000000..920e97d6 --- /dev/null +++ b/apps/quickdeck/tests/evidence/inspector-states-20261011/linux/states.json @@ -0,0 +1,27 @@ +{ + "box": { + "anchor": "top", + "shrink": true, + "set_anchor": "bottom", + "set_shrink": false + }, + "chart": { + "title": true, + "legend": true, + "data_labels": false, + "gridlines": true + }, + "table": { + "header_row": true, + "total_row": false, + "banded_rows": true, + "first_column": false + }, + "colours": [ + "Ember" + ], + "footer": { + "slide_numbers": false, + "date": false + } +} \ No newline at end of file diff --git a/apps/quickdeck/tests/evidence/inspector-states-20261011/receipt.json b/apps/quickdeck/tests/evidence/inspector-states-20261011/receipt.json new file mode 100644 index 00000000..05ef4069 --- /dev/null +++ b/apps/quickdeck/tests/evidence/inspector-states-20261011/receipt.json @@ -0,0 +1,93 @@ +{ + "date": "2026-10-11", + "branch": "feat/quickdeck-inspector-states, from main eb2fd5d1 (#507 merged)", + "files": { + "apps/quickdeck/bundle/main.splash": "c25773fc6d539d7a464ef0c79ef90684dac9be7ccff53f17d54d9b2fd62ee52b", + "apps/quickdeck/bundle/manifest.json": "375c02131477e08ceef11383a384527ea2eab5cb005fbe097e423a8d866cec4e", + "apps/quickdeck/dev-fixture/engine.splash": "1bbb6c33ce05f4d3247992f8d06c149a7518d95c7fd50d7b20b5ceff5b105d88", + "apps/quickdeck/dev-fixture/make_fixture.py": "ed65854751bbefb8f952c7a69f2e44c52bae38de6d7c63bee86507e896d31776", + "apps/quickdeck/tests/ui.py": "9b8c7ab329789c1130fd425fcd186ded8c6b9a84a190a2fcd2d9b096432ba16d", + "crates/shell/src/quickdeck_model_tests.rs": "5c73d74f96082304d3616f96420c6119614e6e2cd102f54e196e02c594c43fec" + }, + "before_bundle": "main eb2fd5d1's apps/quickdeck/bundle (main.splash cfa14f27f09f12b8…, the same as #506's aead68f3)", + "service": "the deck service on main eb2fd5d1: #503 and #507 (inspector states, read from the model)", + "card_host": { + "machine": "macOS 26.6.2, Apple silicon (arm64)", + "binary_sha256": "7b8c302d57e092f999fdc60f7b7e3e0bb762345525642edc3a51e38affdce275", + "budget": "the runtime's 64 ms (this card-host's runtime has no MAKEPAD_SPLASH_BUDGET_MS)", + "after": { + "command": "python3 apps/quickdeck/tests/ui.py --card-host /target/release/card-host --output --work --only fixture --only states", + "window": "22:11 to 22:15 UTC", + "fixture": "passed, light and dark: the ten designed screens, then 11-table and 12-footer; checked against the stand-in: the box's shrink on and top anchor (02), the chart's title, data labels and gridlines on, legend off, colourful (03), Harbor's colour set and Classic's fonts marked (05), the table's header, total and banded rows on and the rest off (11), slide numbers on, no date, the footer text (12); then the first column on and off again after Undo, and the date on and off again after Undo", + "states": "passed, light and dark: damaged, protected, held, unsupported, unsaved changes, storage full, empty library" + }, + "before": { + "command": "the same, with --bundle --grabs-only --only fixture", + "result": "the same journey's frames of main's app on the same fixture, states not checked" + }, + "log": "no script error and no budget overrun in any log of the final runs" + }, + "shell": { + "machine": "Ubuntu 26.04 LTS, x86_64, 72 CPUs, no GPU; weston 14.0.2 headless (pixman)", + "build": "cargo build --locked -p octosense --no-default-features --features app-hub,craft-engines (dev profile), from this branch's tree on main eb2fd5d1", + "binary_sha256": "6de3a0fb1e090310b7ed0a60167dffe643e563802013e17df091895e3985bde3", + "window": "1296 x 698 points (an app area of 1292 x 662)", + "budget": "the runtime's 64 ms", + "runs": { + "shell": "passed (22:11 to 22:15 UTC), light, then dark through the shell's style menu, then restart. In the light pass, each state read, changed, read back from the engine (the shape chosen again, or deck.state asked again after the change) and undone: {\"box\": {\"anchor\": \"top\", \"shrink\": true, \"set_anchor\": \"bottom\", \"set_shrink\": false}, \"chart\": {\"title\": true, \"legend\": true, \"data_labels\": false, \"gridlines\": true}, \"table\": {\"header_row\": true, \"total_row\": false, \"banded_rows\": true, \"first_column\": false}, \"colours\": [\"Ember\"], \"footer\": {\"slide_numbers\": false, \"date\": false}}", + "restart": "passed", + "note": "shell run 5 used the final ui.py; shell run 4, on the same shell binary with ui.py before its open_card retry, passed too" + }, + "log": "no script error and no budget overrun; four 'script_result dropped' warnings (a nested region answered after its holder was drawn again), as in #506", + "model_tests": "on the same tree: cargo test --locked -p octosense-shell --lib quickdeck_model: 27 passed; with --features app-hub: 28 passed" + }, + "rounds": [ + { + "round": "card-host 1", + "result": "failed: the test's switch lookup skipped a switch that starts where a full-width label ends; fixed in ui.py" + }, + { + "round": "card-host 2", + "result": "light passed with every check; the dark log had one 'script time budget exceeded' right after a PNG decode, early in the session" + }, + { + "round": "card-host 3", + "result": "fixture light passed; the states-full log had the same overrun after a PNG decode (the Mac's load averages were 3 to 8)" + }, + { + "round": "card-host 4", + "result": "stopped: superseded by the missing-fonts change" + }, + { + "round": "shell 1", + "result": "failed: after an Undo the inspector was drawn again from the top, so the Box section was below the fold; fixed in the app (refresh_view keeps the inspector's scroll after a design change or an undo) and in ui.py (seek after each change)" + }, + { + "round": "shell 2", + "result": "every state check passed; the sorter step then failed because the state checks left slide 3 current; fixed in ui.py" + }, + { + "round": "shell 3", + "result": "passed: shell (with the state checks), dark and restart; found: the Fonts hint kept deck.open's missing fonts after a theme change; fixed (missing_now)" + }, + { + "round": "shell 4", + "result": "passed: shell (with the state checks), dark and restart, on the shell with the missing-fonts change" + }, + { + "round": "card-host 5", + "result": "failed: the bridge dropped a card's click while the app drew the covers (states-full), so the deck never opened; fixed in ui.py (open_card tries again until the deck's tab shows)" + }, + { + "round": "card-host 6, before run, shell 5", + "result": "the evidence: card-host fixture and states passed, light and dark, with no overrun; main's bundle's frames on the same fixture; the shell journey passed again with the final ui.py" + } + ], + "not_verified": [ + "An imported deck whose own colour set or fonts aren't among the engine's (the hint that names them): no such deck was opened with the real engine", + "A box whose anchor is justified or distributed, or whose autofit is resize, with the real engine (their hints)", + "Present, export and the other screens this PR doesn't touch, beyond the journey", + "Opening a .docx or .xlsx from the device (unsupported:): the file dialog can't be driven in a hidden window", + "A hidden shell on macOS; windows narrower than 1180 points; phones" + ] +} diff --git a/apps/quickdeck/tests/ui.py b/apps/quickdeck/tests/ui.py index c76122b0..468074a6 100644 --- a/apps/quickdeck/tests/ui.py +++ b/apps/quickdeck/tests/ui.py @@ -10,7 +10,9 @@ place of engine(), and make_fixture.py's sample decks (the approved designs' own sample text): every designed screen at the designs' 1536 x 1024, light and dark, each grab put beside its design in - compare/ + compare/, then a table's and the footer's controls; the inspector's + switches and marks are checked against what the stand-in reports, + and a change and its undo against what it reports after them states the same stand-in: a damaged deck, a protected, a held and an unsupported presentation, the unsaved-changes bar, the storage-full bar, and the empty library of a first run, light and dark @@ -28,7 +30,10 @@ a chart inserted and its data changed, a layout, a theme, the sorter (duplicate, hide, a section), notes, undo and redo, save, export (.pptx, PDF and pictures), Present and its notes, light, then dark - through the shell's own style menu + through the shell's own style menu; in the light pass the + inspector's states follow real changes and their undo: a box's + anchor and autofit, a chart element, a table option, the colour set + and the footer restart the same home again: the library, the deck's slides and covers, the place it was left at, and its exports @@ -92,6 +97,9 @@ class Driver: # runtime's 64 ms: the budget is wall-clock time, so a loaded machine can # overrun it in any handler (--budget-ms). budget_ms = None + # Whether the journeys check the inspector's states (--grabs-only turns it + # off, to take another bundle's frames for a before/after comparison). + check_states = True def __init__(self, port): self.port = port @@ -467,8 +475,18 @@ def labels(app, text): def open_card(app, title): - """Home: open a deck from its card.""" - app.tap(title, "Label", below=380) + """Home: open a deck from its card. A click the bridge couldn't deliver + while the app was busy (drawing covers) is tried again, until the deck's + tab shows.""" + for _ in range(4): + app.tap(title, "Label", below=380) + try: + wait_until(lambda: any(w["ty"] == "Label" and w.get("t") in (title, title + " •") and w["r"][1] < app.area()[1] + 70 + for w in app.widgets()), "no tab", timeout=8) + return + except AssertionError: + pass + raise AssertionError(f"{title!r} never opened from its card") def home_tab(app): @@ -500,6 +518,8 @@ def fixture_journey(app, out, dark): app.images(4) click_slide(app, 250, 280) # the bullet box: chosen app.find("Applies to", "Label") + # As the stand-in reports the box (design 02): its text shrinks to fit, top. + expect("the box shrinking its text, top", lambda: switch_on(app, "Shrink text when it overflows") and segment(app, "Vertical alignment") == 0) click_slide(app, 250, 280) # tapped again: its text in place wait_until(lambda: any(w["ty"] == "TextInput" and (w.get("t") or "").startswith("Northwest led") for w in app.widgets()), "the box's text field never came") field = [w for w in app.widgets() if w["ty"] == "TextInput" and (w.get("t") or "").startswith("Northwest led")][0]["r"] @@ -515,6 +535,9 @@ def fixture_journey(app, out, dark): app.find("Insert chart", "Button") click_slide(app, 480, 300) app.find("Elements", "Label") + # Design 03: title, data labels and gridlines on, no legend, colourful. + expect("the chart's elements", lambda: switch_on(app, "Title") and not switch_on(app, "Legend") and switch_on(app, "Data labels") + and switch_on(app, "Gridlines") and segment(app, "Colours", 50) == 0) app.grab(out, f"{p}03-chart") app.tap("Chart", "Label", below=70) # the strip closes again app.gone("Insert chart") @@ -530,6 +553,9 @@ def fixture_journey(app, out, dark): seg(app, "Theme") go_to(app, 1) app.find("Themes", "Label") + # The deck's colour set and font pair (deck.open): Harbor's own, whose + # fonts are Classic's. + expect("Harbor's colours and Classic's fonts", lambda: {"Harbor", "Classic"} <= selected_texts(app)) app.grab(out, f"{p}05-theme") # 06: the sorter, slides 5 and 6 chosen. @@ -584,6 +610,32 @@ def fixture_journey(app, out, dark): app.images(4) app.grab(out, f"{p}10-notes") + # 11: slide 6's table and its options (design 10's table: a header row, + # banded rows and a total row). + seg(app, "Format") + click_slide(app, 496, 284) + app.find("Header row", "Label", timeout=30) + expect("the table's options", lambda: switch_on(app, "Header row") and switch_on(app, "Total row") and switch_on(app, "Banded rows") + and not switch_on(app, "First column") and not switch_on(app, "Last column") and not switch_on(app, "Banded columns")) + app.grab(out, f"{p}11-table") + # A table option and its undo: the switch follows the table. + app.click(*switch_point(app, "First column")) + expect("the first column on", lambda: switch_on(app, "First column")) + app.tap("Undo", "Label") + expect("the first column off again after Undo", lambda: not switch_on(app, "First column")) + + # 12: the footer (design 05: slide numbers on, no date). + seg(app, "Theme") + x, y, w, h = app.area() + seek(app, "Slide numbers", "Label", at=(x + w - 170, y + h / 2)) + expect("the deck's footer", lambda: switch_on(app, "Slide numbers") and not switch_on(app, "Date") + and any(f["ty"] == "TextInput" and f.get("val") == "Northwind Cooperative" for f in app.widgets())) + app.grab(out, f"{p}12-footer") + app.click(*switch_point(app, "Date")) + expect("the date on", lambda: switch_on(app, "Date")) + app.tap("Undo", "Label") + expect("the date off again after Undo", lambda: not switch_on(app, "Date")) + def seek(app, text, kind="Button", exact=True, at=None, timeout=60.0): """Scroll the page under `at` (the middle of the window by default) until @@ -608,6 +660,56 @@ def reach(app, text, kind="Button", at=None): app.click(fx + fw / 2, fy + fh / 2) +def switch_on(app, label): + """The switch on `label`'s row (a Toggle reports its state as `c`).""" + lx, ly, lw, lh = app.find(label, "Label")["r"] + cy = ly + lh / 2 + found = [w for w in app.widgets() if "c" in w and w["r"][0] > lx and w["r"][1] <= cy <= w["r"][1] + w["r"][3]] + assert found, f"no switch beside {label!r}" + return found[0]["c"] == 1 + + +def switch_point(app, label): + """Where to click `label`'s switch.""" + lx, ly, lw, lh = app.find(label, "Label")["r"] + cy = ly + lh / 2 + found = [w["r"] for w in app.widgets() if "c" in w and w["r"][0] > lx and w["r"][1] <= cy <= w["r"][1] + w["r"][3]] + assert found, f"no switch beside {label!r}" + x, y, w, h = found[0] + return x + w / 2, y + h / 2 + + +def selected_texts(app): + """The words inside every view that says it is selected (View.selected).""" + widgets = app.widgets() + views = [w["r"] for w in widgets if w.get("selected") == "true"] + out = set() + for w in widgets: + if w["ty"] != "Label" or not w.get("t"): + continue + x, y, ww, hh = w["r"] + for vx, vy, vw, vh in views: + if vx - 1 <= x and x + ww <= vx + vw + 1 and vy - 1 <= y and y + hh <= vy + vh + 1: + out.add(w["t"]) + return out + + +def segment(app, label, below=60): + """Which of the segments under `label` is selected (0-based), or -1.""" + lx, ly, lw, lh = app.find(label, "Label")["r"] + row = sorted((w for w in app.widgets() if "selected" in w and ly + lh <= w["r"][1] <= ly + lh + below), key=lambda w: w["r"][0]) + assert row, f"no segments under {label!r}" + on = [i for i, w in enumerate(row) if w["selected"] == "true"] + return on[0] if on else -1 + + +def expect(what, check, timeout=30.0): + """Wait until the inspector shows `what` (skipped with --grabs-only).""" + if not Driver.check_states: + return + wait_until(check, f"the inspector never showed {what}", timeout=timeout) + + def tile_number(app, n): """The sorter: tap slide n's tile, above its number, scrolling the sorter until both are in view (a short window shows one row and a half).""" @@ -818,6 +920,8 @@ def shell_journey(app, out, dark): app.tap("Ember", "Label") app.images(2, timeout=120) app.grab(out, f"{p}06-theme") + if not dark: + states_follow(app, out) # notes seg(app, "Notes") note = [w for w in app.widgets() if w["ty"] == "TextInput" and w["r"][0] > app.area()[2] - 360] @@ -870,6 +974,150 @@ def shell_journey(app, out, dark): app.tap("Exit", "Button") +def states_follow(app, out): + """The inspector shows what the engine reports, and follows a real change + and its undo: a box's anchor and autofit, a chart element, a table option, + the colour set and the footer (SERVICE.md "Inspector states (#507)"). + After each change the shape is chosen again, or the design read again, so + the check is of what the engine reports, not of what the app showed at + once; after each Undo, the same.""" + x, y, w, h = app.area() + inspector = (x + w - 170, y + h / 2) + seen = {} + + def settle(): + time.sleep(2.5) + + def choose_again(px, py, ready): + click_slide(app, 930, 525) # the slide's empty corner: nothing chosen + app.gone(ready, timeout=30) + settle() + click_slide(app, px, py) + app.find(ready, "Label", timeout=30) + + # A box: its vertical alignment and autofit (deck.format_state). + seg(app, "Format") + go_to(app, 2) + click_slide(app, 300, 200) + app.find("Applies to", "Label", timeout=30) + seek(app, "Vertical alignment", "Label", at=inspector) + expect("the box's anchor", lambda: segment(app, "Vertical alignment") >= 0) + anchor, shrink = segment(app, "Vertical alignment"), switch_on(app, "Shrink text when it overflows") + target = 2 if anchor != 2 else 0 + seen["box"] = {"anchor": ["top", "middle", "bottom"][anchor], "shrink": shrink, "set_anchor": ["top", "middle", "bottom"][target], "set_shrink": not shrink} + lx, ly, lw, lh = app.find("Vertical alignment", "Label")["r"] + row = sorted((v for v in app.widgets() if "selected" in v and ly + lh <= v["r"][1] <= ly + lh + 60), key=lambda v: v["r"][0]) + bx, by, bw, bh = row[target]["r"] + app.click(bx + bw / 2, by + bh / 2) + expect("the new anchor", lambda: segment(app, "Vertical alignment") == target) + app.click(*switch_point(app, "Shrink text when it overflows")) + expect("the box's autofit changed", lambda: switch_on(app, "Shrink text when it overflows") != shrink) + choose_again(300, 200, "Applies to") + seek(app, "Vertical alignment", "Label", at=inspector) + expect("the engine's new anchor and autofit", lambda: segment(app, "Vertical alignment") == target + and switch_on(app, "Shrink text when it overflows") != shrink) + app.grab(out, "10-box") + app.tap("Undo", "Label") + app.tap("Undo", "Label") + settle() + seek(app, "Vertical alignment", "Label", at=inspector) + expect("the anchor and autofit back after Undo", lambda: segment(app, "Vertical alignment") == anchor + and switch_on(app, "Shrink text when it overflows") == shrink) + settle() + expect("the anchor and autofit still back", lambda: segment(app, "Vertical alignment") == anchor + and switch_on(app, "Shrink text when it overflows") == shrink) + app.grab(out, "10b-box-undone") + + # A chart element: data labels (deck.slide's chart). + go_to(app, 4) + app.images(2, timeout=60) + click_slide(app, 480, 300) + app.find("Elements", "Label", timeout=30) + seek(app, "Data labels", "Label", at=inspector) + labels = switch_on(app, "Data labels") + seen["chart"] = {"title": switch_on(app, "Title"), "legend": switch_on(app, "Legend"), "data_labels": labels, "gridlines": switch_on(app, "Gridlines")} + app.click(*switch_point(app, "Data labels")) + expect("the data labels changed", lambda: switch_on(app, "Data labels") != labels) + choose_again(480, 300, "Elements") + seek(app, "Data labels", "Label", at=inspector) + expect("the engine's data labels", lambda: switch_on(app, "Data labels") != labels) + app.images(2, timeout=60) + app.grab(out, "11-chart") + app.tap("Undo", "Label") + settle() + seek(app, "Data labels", "Label", at=inspector) + expect("the data labels back after Undo", lambda: switch_on(app, "Data labels") == labels) + settle() + expect("the data labels still back", lambda: switch_on(app, "Data labels") == labels) + app.grab(out, "11b-chart-undone") + + # A table option: the total row (deck.slide's table). + go_to(app, 3) + app.tap("Table", "Label", below=70) + app.tap("Insert table", "Button") + app.find("Header row", "Label", timeout=60) + seek(app, "Total row", "Label", at=inspector) + total = switch_on(app, "Total row") + seen["table"] = {"header_row": switch_on(app, "Header row"), "total_row": total, "banded_rows": switch_on(app, "Banded rows"), + "first_column": switch_on(app, "First column")} + app.click(*switch_point(app, "Total row")) + expect("the total row changed", lambda: switch_on(app, "Total row") != total) + # The engine centres a new table on the slide (deckcraft insert.table). + choose_again(480, 270, "Header row") + seek(app, "Total row", "Label", at=inspector) + expect("the engine's total row", lambda: switch_on(app, "Total row") != total) + app.images(2, timeout=60) + app.grab(out, "12-table") + app.tap("Undo", "Label") + settle() + seek(app, "Total row", "Label", at=inspector) + expect("the total row back after Undo", lambda: switch_on(app, "Total row") == total) + settle() + expect("the total row still back", lambda: switch_on(app, "Total row") == total) + app.grab(out, "12b-table-undone") + app.tap("Undo", "Label") # and the table goes + app.gone("Header row", timeout=30) + + # The colour set (deck.state): Ocean, then Undo back to the theme's own. + seg(app, "Theme") + app.find("Colours", "Label", timeout=30) + expect("the theme's colour set marked", lambda: "Ember" in selected_texts(app)) + seen["colours"] = sorted(t for t in selected_texts(app) if t != "Title") + ox, oy, ow, oh = seek(app, "Ocean", "Label", at=inspector) + app.click(ox + ow / 2, oy - 8) + expect("the Ocean colour set marked", lambda: "Ocean" in selected_texts(app), timeout=60) + settle() + expect("the engine's colour set, Ocean", lambda: "Ocean" in selected_texts(app)) + app.images(2, timeout=60) + app.grab(out, "13-colours") + app.tap("Undo", "Label") + expect("Ocean unmarked after Undo", lambda: "Ocean" not in selected_texts(app), timeout=60) + app.scroll(-3000, at=inspector) + expect("the theme's colour set marked again after Undo", lambda: "Ember" in selected_texts(app), timeout=30) + app.grab(out, "13b-colours-undone") + + # The footer (deck.state): slide numbers, then Undo. + seek(app, "Slide numbers", "Label", at=inspector) + numbers = switch_on(app, "Slide numbers") + seen["footer"] = {"slide_numbers": numbers, "date": switch_on(app, "Date")} + app.click(*switch_point(app, "Slide numbers")) + expect("the slide numbers changed", lambda: switch_on(app, "Slide numbers") != numbers, timeout=60) + settle() + expect("the engine's slide numbers", lambda: switch_on(app, "Slide numbers") != numbers) + app.images(2, timeout=60) + app.grab(out, "14-footer") + app.tap("Undo", "Label") + settle() + seek(app, "Slide numbers", "Label", at=inspector) + expect("the slide numbers back after Undo", lambda: switch_on(app, "Slide numbers") == numbers, timeout=60) + settle() + expect("the slide numbers still back", lambda: switch_on(app, "Slide numbers") == numbers) + app.grab(out, "14b-footer-undone") + (out / "states.json").write_text(json.dumps(seen, indent=1)) + app.scroll(-3000, at=inspector) + go_to(app, 4) # where the journey was: the rest of it starts from slide 4 + + def restart(app, out): """The same home again: the deck in the library with its cover and the slide count the last save wrote, opened at the slide it was left at.""" @@ -944,11 +1192,12 @@ def __exit__(self, *exc): self.path.rmdir() -def dev_bundle(out): - """A scratch copy of the bundle with the stand-in engine in place of engine().""" +def dev_bundle(out, source=None): + """A scratch copy of the bundle (or `source`) with the stand-in engine in + place of engine().""" if out.exists(): shutil.rmtree(out) - shutil.copytree(BUNDLE, out) + shutil.copytree(source or BUNDLE, out) main = out / "main.splash" text = main.read_text() start = text.index("fn engine(method, args, done){") @@ -974,7 +1223,7 @@ def card_host_runs(args, runs): assert port_free(args.port), f"port {args.port} is taken" with tempfile.TemporaryDirectory(prefix="quickdeck-card-host-", dir=args.work) as scratch: scratch = Path(scratch) - bundle = dev_bundle(scratch / "bundle") + bundle = dev_bundle(scratch / "bundle", args.bundle) def session(name, which, flags, steps, dark): data = scratch / f"{name}-{'dark' if dark else 'light'}" @@ -1056,9 +1305,12 @@ def main(): parser.add_argument("--only", choices=["shell", "restart", "missing", "fixture", "states"], action="append") parser.add_argument("--budget-ms", type=int, help="MAKEPAD_SPLASH_BUDGET_MS for the hosts (default: the runtime's 64 ms); for a loaded machine") parser.add_argument("--grab-scale", type=float, default=0.5, help="the grabs' scale (the bridge's /g scale): 0.5 is one pixel per point on a 2x screen") + parser.add_argument("--bundle", type=Path, help="another bundle for the card-host runs (default: apps/quickdeck/bundle), for a before/after comparison") + parser.add_argument("--grabs-only", action="store_true", help="take the frames without checking the inspector's states (for another bundle)") args = parser.parse_args() Driver.scale = args.grab_scale Driver.budget_ms = args.budget_ms + Driver.check_states = not args.grabs_only card_host_only = ("missing", "fixture", "states") runs = args.only or (["shell", "restart"] if args.shell else []) + (list(card_host_only) if args.card_host else []) assert runs, "give --shell, --card-host or both" diff --git a/crates/shell/src/quickdeck_model_tests.rs b/crates/shell/src/quickdeck_model_tests.rs index 54ded229..c8f95182 100644 --- a/crates/shell/src/quickdeck_model_tests.rs +++ b/crates/shell/src/quickdeck_model_tests.rs @@ -398,3 +398,90 @@ fn the_shipped_manifest_asks_for_the_designs_window_size() { assert_eq!(manifest.window, Some(octosense_app_contract::WindowHint::new(1536, 1024))); assert_eq!(manifest.capabilities, ["storage", "files", "deck", "model"]); } + +#[test] +fn a_chart_shows_its_own_elements_and_a_change_on_its_way() { + // SERVICE.md "Inspector states (#507)": deck.slide's chart says legend + // (a position or "none"), data_labels, gridlines and palette. + let state = |chart: &str, pending: &str| quickdeck_model(&format!("chart_state({chart}, {pending}).to_json()")); + assert_eq!( + state("{title: \"Revenue\" legend: \"none\" data_labels: true gridlines: true palette: \"colorful1\"}", "{}"), + json!({"title": true, "legend": false, "data_labels": true, "gridlines": true, "palette": "colorful1", "mono": false}), + "design 03's chart" + ); + assert_eq!(state("{title: \"\" legend: \"r\" data_labels: false gridlines: false palette: \"monochrome3\"}", "{}")["legend"], json!(true)); + assert_eq!(state("{title: \"\" legend: \"r\" palette: \"monochrome3\"}", "{}")["mono"], json!(true)); + // An older service said none of it: the engine's defaults (a legend + // below, gridlines, no data labels, colourful). + assert_eq!( + state("{title: \"Chart Title\"}", "{}"), + json!({"title": true, "legend": true, "data_labels": false, "gridlines": true, "palette": "colorful1", "mono": false}) + ); + // A change shows at once, until deck.slide answers. + let pending = state("{title: \"\" legend: \"b\" data_labels: false gridlines: true}", "{legend: \"none\" data_labels: true title: true}"); + assert_eq!(pending["legend"], json!(false)); + assert_eq!(pending["data_labels"], json!(true)); + assert_eq!(pending["title"], json!(true)); + assert_eq!(quickdeck_model("legend_shown(nil).to_json()"), json!(false)); +} + +#[test] +fn a_table_shows_its_own_options() { + let options = |table: &str, pending: &str| quickdeck_model(&format!("table_options({table}, {pending}).to_json()")); + // Design 10's table: a header row, banded rows, a total row. + assert_eq!( + options("{options: {header_row: true total_row: true banded_rows: true first_column: false last_column: false banded_columns: false}}", "{}"), + json!({"header_row": true, "total_row": true, "banded_rows": true, "first_column": false, "last_column": false, "banded_columns": false}) + ); + // Without options (an older service): the engine's defaults. + assert_eq!( + options("{}", "{}"), + json!({"header_row": true, "total_row": false, "banded_rows": true, "first_column": false, "last_column": false, "banded_columns": false}) + ); + assert_eq!(options("{options: {first_column: false}}", "{first_column: true}")["first_column"], json!(true), "a change on its way"); +} + +#[test] +fn a_box_shows_its_anchor_and_autofit() { + let state = |fmt: &str| quickdeck_model(&format!("box_state({fmt}).to_json()")); + assert_eq!(state("{anchor: \"top\" autofit: \"shrink\"}"), json!({"anchor": "top", "autofit": "shrink", "shrink": true}), "design 02's box"); + assert_eq!(state("{anchor: \"middle\" autofit: \"resize\"}"), json!({"anchor": "middle", "autofit": "resize", "shrink": false})); + assert_eq!(state("{anchor: \"distributed\" autofit: \"none\"}")["anchor"], json!("distributed"), "an imported deck's"); + assert_eq!(state("nil"), json!({"anchor": "", "autofit": "", "shrink": false}), "before format_state answers"); +} + +#[test] +fn the_footer_and_the_marks_follow_the_deck() { + let footer = |f: &str, pending: &str| quickdeck_model(&format!("footer_state({f}, {pending}).to_json()")); + // Design 05: slide numbers on, no date. + assert_eq!( + footer("{slide_numbers: true footer: true footer_text: \"Northwind Cooperative\" date: false hide_on_title: false}", "{}"), + json!({"slide_numbers": true, "date": false, "footer": true, "footer_text": "Northwind Cooperative"}) + ); + assert_eq!(footer("nil", "{}"), json!({"slide_numbers": false, "date": false, "footer": false, "footer_text": ""})); + assert_eq!(footer("{date: false}", "{date: true}")["date"], json!(true), "a change on its way"); + // The colour set: one picked and on its way, else the deck's, else its theme's. + let colours = |chosen: &str, scheme: &str| quickdeck_model(&format!("scheme_mark(\"{chosen}\", \"{scheme}\", \"Harbor\").to_json()")); + assert_eq!(colours("", "Ocean"), json!("Ocean")); + assert_eq!(colours("Berry", "Ocean"), json!("Berry")); + assert_eq!(colours("", ""), json!("Harbor")); + // The font pair: by name, else by the deck's families (a built-in theme's + // own pair is named after the theme; Harbor's fonts are Classic's). + let pairs = "[{name: \"Inter\" major: \"Inter\" minor: \"Inter\"}, {name: \"Classic\" major: \"Merriweather\" minor: \"Open Sans\"}, \ + {name: \"Humanist\" major: \"Merriweather\" minor: \"Open Sans\"}]"; + let fonts = |chosen: &str, scheme: &str, major: &str, minor: &str| { + quickdeck_model(&format!("font_mark(\"{chosen}\", \"{scheme}\", {pairs}, \"{major}\", \"{minor}\").to_json()")) + }; + assert_eq!(fonts("", "Humanist", "Merriweather", "Open Sans"), json!("Humanist"), "the deck's pair by name"); + assert_eq!(fonts("", "Harbor", "Merriweather", "Open Sans"), json!("Classic"), "the theme's own pair by its families"); + assert_eq!(fonts("Inter", "Harbor", "Merriweather", "Open Sans"), json!("Inter"), "a pick on its way"); + assert_eq!(fonts("", "Office", "Calibri Light", "Calibri"), json!(""), "an imported deck's own fonts"); +} + +#[test] +fn an_office_document_that_isnt_a_presentation_is_explained() { + // #507: a .docx or .xlsx answers unsupported:. + let detail = quickdeck_model("open_detail(\"unsupported\").to_json()"); + let text = detail.as_str().unwrap(); + assert!(text.contains("Word documents") && text.contains("Excel workbooks"), "{text}"); +}