T.DE1: merge upstream main while preserving German hyphenation

This commit is contained in:
hslrmnp27
2026-10-28 14:52:15 +01:00
71 changed files with 2010 additions and 675 deletions
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+4 -2
View File
@@ -26,8 +26,10 @@ permissions:
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: designcraft
app-dir: DesignCraft
install-relative-path: Programs/ArtCraft/DesignCraft
install-scope: perUser
Generated
+19 -19
View File
@@ -1004,7 +1004,7 @@ dependencies = [
[[package]]
name = "craft-atomic"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"rustix 1.1.5",
]
@@ -1012,7 +1012,7 @@ dependencies = [
[[package]]
name = "craft-cms"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"rayon",
]
@@ -1020,17 +1020,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1053,7 +1053,7 @@ dependencies = [
[[package]]
name = "craft-keymap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-i18n",
"craft-ui",
@@ -1065,7 +1065,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"bytemuck",
"hayro",
@@ -1078,7 +1078,7 @@ dependencies = [
[[package]]
name = "craft-prepress"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-cms",
"craft-pdf",
@@ -1091,7 +1091,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.9.1",
@@ -1104,7 +1104,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1125,7 +1125,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"chrono",
"dunce",
@@ -1150,7 +1150,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -2635,7 +2635,7 @@ dependencies = [
[[package]]
name = "hayro"
version = "0.7.1"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"bytemuck",
"hayro-interpret",
@@ -2655,7 +2655,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"brotli",
"hayro-postscript",
@@ -2664,7 +2664,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -2682,7 +2682,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -2700,12 +2700,12 @@ dependencies = [
[[package]]
name = "hayro-postscript"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -3300,7 +3300,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
+17 -17
View File
@@ -12,12 +12,12 @@ repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
# Shared reader and writer; publish the immutable revision before this consumer.
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog.
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui", "ui"] }
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] }
# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch.
craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["flatten"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] }
craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["flatten"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@@ -80,20 +80,20 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
indd = { git = "https://github.com/storytold/indd-utils", branch = "main" }
# Which paths stored in documents may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Atomic file replacement for saves and exports (craft-libs).
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# File › Print through the system print spooler (craft-libs).
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# ICC colour management: working spaces, transforms, black point compensation (craft-libs).
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
# The application bar as the window's title bar (craft-libs).
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
eframe = { version = "0.37", default-features = false }
@@ -131,16 +131,16 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
# The hayro crates from craft-libs' vendor/ directory (pdfcraft's, plus the plate mode and decode
# limits craft-pdf and craft-prepress need); same revision as craft-pdf.
hayro = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.3.0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+3 -1
View File
@@ -115,7 +115,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|art|trim|bleed|media|contentVisible|contentAll}`, Place PDF dialog): the graphic spans that page box, the document stores the choice, and IDML keeps it as `PDFCrop`.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).
- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports.
- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame.
@@ -381,6 +381,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change.
- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change.
- **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
- **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
- **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.
@@ -401,6 +402,7 @@ Newest first.
- **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change.
- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change.
- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change.
- **2026-10-24** · The Story Editor has InDesign's vertical depth ruler between the style column and the text, and View › Story Editor › Show/Hide Depth Ruler (`view.storyEditorDepthRuler`) shows or hides it in the Story Editor, remembered with the preferences. The ruler is marked in the document's vertical ruler units with how deep the story's text fills its frames, counted through its columns and threaded frames (lines set side by side, in the sub-columns of a split paragraph or on both sides of a wrap object, count once), and is shaded down to the overset point, where a red line now crosses the editor. No readiness percentage change.
- **2026-10-24** · Split Note (`note.split {story, id, at}`, the Notes panel's Split Note button): as in InDesign, a note splits in two at the insertion point in its text. DesignCraft moves the text after it into a new note by the same author, anchored right after the first. No readiness percentage change.
- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change.
- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change.
@@ -0,0 +1,64 @@
//! File › Export PDF on the `designcraft-cli perf` stress document, timed.
//!
//! ```text
//! cargo run --release -p designcraft-cli --example pdf_export -- [--pages N] [--runs N] [--out FILE.pdf]
//! ```
//!
//! Builds the synthetic document (200 pages, 1M characters in threaded stories, 500 placed
//! images, gradients, a drop shadow per spread), opens it in a headless session and runs
//! `file.exportPdf` on all pages: one warm-up export (composition), then `--runs` timed ones.
//! Prints each run, the median and the minimum. `--out` keeps the last PDF, written with a fixed
//! creation date so two builds can be compared byte for byte.
use std::time::Instant;
use designcraft_cli::stress;
fn main() -> Result<(), String> {
let mut spec = stress::Spec::default();
let mut runs = 7usize;
let mut out: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
let mut num = || args.next().and_then(|v| v.parse::<usize>().ok()).ok_or(format!("{a} needs a number"));
match a.as_str() {
"--pages" => spec.pages = num()?,
"--runs" => runs = num()?.max(1),
"--out" => out = Some(args.next().ok_or("--out needs a file")?),
other => return Err(format!("unknown option `{other}`")),
}
}
let doc = stress::synthetic(&spec)?;
let bytes = designcraft_format::save(&doc).map_err(|e| e.to_string())?;
let dir = std::env::temp_dir().join(format!("designcraft-pdf-bench-{}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let file = dir.join("stress.designcraft");
let keep = out.is_some();
let pdf = out.map_or_else(|| dir.join("stress.pdf"), std::path::PathBuf::from);
std::fs::write(&file, bytes).map_err(|e| e.to_string())?;
let mut s = designcraft_engine::Session::new();
s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?;
let params = serde_json::json!({"path": pdf, "created": 1_700_000_000});
let mut export = || -> Result<f64, String> {
let t = Instant::now();
s.execute("file.exportPdf", &params).map_err(|e| e.to_string())?;
Ok(t.elapsed().as_secs_f64() * 1000.0)
};
let warm = export()?;
println!("{} pages, warm-up export (with composition) {warm:.1} ms", doc.page_count());
let mut ms = Vec::with_capacity(runs);
for i in 0..runs {
let t = export()?;
println!("run {}: {t:.1} ms", i + 1);
ms.push(t);
}
ms.sort_by(f64::total_cmp);
let size = std::fs::metadata(&pdf).map_or(0, |m| m.len());
println!("median {:.1} ms, min {:.1} ms, {} kB", ms[ms.len() / 2], ms[0], size / 1024);
let _ = std::fs::remove_file(&file);
if !keep {
let _ = std::fs::remove_file(&pdf);
}
let _ = std::fs::remove_dir(&dir);
Ok(())
}
+5
View File
@@ -0,0 +1,5 @@
//! Headless DesignCraft's shared parts: the synthetic stress document of `designcraft-cli perf`,
//! which the PDF export benchmark (`examples/pdf_export.rs`) exports too.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod stress;
+4 -239
View File
@@ -10,253 +10,18 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, StoryId};
use designcraft_geom::{Affine, Rect, Vec2, shapes};
use designcraft_doc::{Document, SpreadRef, StoryId};
use designcraft_geom::{Affine, Rect, Vec2};
use designcraft_render::{Placed, RenderOptions, Renderer};
use designcraft_cli::stress::{Spec, synthetic};
/// Device pixels per point at 100% (HiDPI canvas).
const HIDPI: f64 = 2.0;
const W: u32 = 2880;
const H: u32 = 1800;
/// Deterministic xorshift in 0..1.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 % 1_000_000) as f64 / 1_000_000.0
}
fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str {
v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)]
}
}
const WORDS: &[&str] = &[
"the",
"grid",
"page",
"margin",
"column",
"rhythm",
"baseline",
"reader",
"story",
"frame",
"quiet",
"layout",
"type",
"paragraph",
"composer",
"weighs",
"every",
"line",
"break",
"texture",
"even",
"hyphen",
"image",
"spread",
"pulse",
"caption",
"colour",
"swatch",
"ink",
"tint",
"gradient",
"style",
"voice",
"printer",
"proof",
"bleed",
"a",
"of",
"and",
"to",
"in",
"with",
"for",
"is",
"on",
"that",
"by",
"it",
"as",
"careful",
"measure",
"leading",
"kerning",
"tracking",
"glyph",
"serif",
"italic",
"heading",
"running",
"folio",
];
/// `chars` characters of original filler text in paragraphs of ~400 characters.
fn filler(r: &mut Rng, chars: usize) -> String {
let mut s = String::with_capacity(chars + 16);
let mut para = 0;
while s.len() < chars {
let w = r.pick(WORDS);
if para == 0 {
let mut c = w.chars();
if let Some(f) = c.next() {
s.extend(f.to_uppercase());
s.push_str(c.as_str());
}
} else {
s.push_str(w);
}
para += w.len() + 1;
if para > 400 {
s.push_str(".\n");
para = 0;
} else {
s.push(' ');
}
}
s
}
/// Procedural RGB image, PNG-encoded.
fn art_png(w: u32, h: u32, seed: f32) -> Vec<u8> {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0);
px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]);
}
}
designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png()
}
/// Parameters of the synthetic stress document.
pub struct Spec {
pub pages: usize,
pub frames_per_page: usize,
pub frames_per_story: usize,
pub chars: usize,
pub images: usize,
pub assets: usize,
}
impl Default for Spec {
fn default() -> Self {
Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 }
}
}
/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text
/// frames, and a strip of placed images (one with a drop shadow per spread).
pub fn synthetic(spec: &Spec) -> Result<Document, String> {
let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() });
let lid = d.default_layer();
d.swatches.push(Swatch {
name: "Perf Gradient".into(),
value: SwatchValue::Gradient {
gradient: Gradient {
kind: GradientKind::Linear,
stops: vec![GradientStop::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))],
},
},
locked: false,
named: true,
hidden: false,
});
let mut rng = Rng(0x5eed);
let (aw, ah) = (800u32, 600u32);
let assets: Vec<AssetId> = (0..spec.assets.max(1))
.map(|k| {
let id = AssetId(d.alloc());
let data = art_png(aw, ah, k as f32 * 0.37);
d.assets.insert(
id,
Arc::new(Asset {
id,
name: format!("art-{k}.png"),
mime: "image/png".into(),
data: Arc::new(data),
pixels: Some((aw, ah)),
..Default::default()
}),
);
id
})
.collect();
let total_frames = spec.pages * spec.frames_per_page;
let stories = total_frames.div_ceil(spec.frames_per_story.max(1));
let per_story = spec.chars / stories.max(1);
let rows = spec.frames_per_page.div_ceil(2);
let (top, strip) = (48.0, 130.0);
let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64;
let mut prev: Option<ItemId> = None;
let mut n_frame = 0usize;
let mut placed_images = 0usize;
for abs in 0..spec.pages {
let Some((si, pi)) = d.page_loc(abs) else { break };
let sr = SpreadRef::Doc(si);
let x0 = d.spreads[si].pages[pi].x;
// Gradient band.
let id = ItemId(d.alloc());
let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0)));
band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() };
band.fill.gradient_angle = Some(rng.next() * 90.0);
d.insert_item(sr, band, None).map_err(|e| e.to_string())?;
// Text frames.
for k in 0..spec.frames_per_page {
let (c, r) = (k % 2, k / 2);
let fx = x0 + 36.0 + c as f64 * 280.0;
let fy = top + r as f64 * row_h;
let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0);
let first = n_frame.is_multiple_of(spec.frames_per_story.max(1));
let text = if first { filler(&mut rng, per_story) } else { String::new() };
let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).map_err(|e| e.to_string())?;
if let (false, Some(p)) = (first, prev) {
d.thread(p, fid).map_err(|e| e.to_string())?;
}
prev = Some(fid);
n_frame += 1;
}
// Images: spread the requested count evenly over the pages.
let want = (spec.images * (abs + 1)) / spec.pages - placed_images;
for k in 0..want {
let w = 540.0 / want as f64;
let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0);
let asset = assets[(placed_images + k) % assets.len()];
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
let (nw, nh) = (aw as f64, ah as f64);
let s = (r.width() / nw).max(r.height() / nh);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s),
clip: None,
auto_fit: designcraft_doc::Fitting::FillProportionally,
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
if k == 0 && pi == 0 {
it.effects.drop_shadow.on = true;
}
d.insert_item(sr, it, None).map_err(|e| e.to_string())?;
}
placed_images += want;
}
Ok(d)
}
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
let mut v: Vec<f64> = (0..runs.max(1))
.map(|_| {
+244
View File
@@ -0,0 +1,244 @@
//! The synthetic stress document `designcraft-cli perf` measures, also used by the PDF export
//! benchmark (`examples/pdf_export.rs`).
use std::sync::Arc;
use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue};
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef};
use designcraft_geom::{Affine, Rect, shapes};
/// Deterministic xorshift in 0..1.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 % 1_000_000) as f64 / 1_000_000.0
}
fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str {
v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)]
}
}
const WORDS: &[&str] = &[
"the",
"grid",
"page",
"margin",
"column",
"rhythm",
"baseline",
"reader",
"story",
"frame",
"quiet",
"layout",
"type",
"paragraph",
"composer",
"weighs",
"every",
"line",
"break",
"texture",
"even",
"hyphen",
"image",
"spread",
"pulse",
"caption",
"colour",
"swatch",
"ink",
"tint",
"gradient",
"style",
"voice",
"printer",
"proof",
"bleed",
"a",
"of",
"and",
"to",
"in",
"with",
"for",
"is",
"on",
"that",
"by",
"it",
"as",
"careful",
"measure",
"leading",
"kerning",
"tracking",
"glyph",
"serif",
"italic",
"heading",
"running",
"folio",
];
/// `chars` characters of original filler text in paragraphs of ~400 characters.
fn filler(r: &mut Rng, chars: usize) -> String {
let mut s = String::with_capacity(chars + 16);
let mut para = 0;
while s.len() < chars {
let w = r.pick(WORDS);
if para == 0 {
let mut c = w.chars();
if let Some(f) = c.next() {
s.extend(f.to_uppercase());
s.push_str(c.as_str());
}
} else {
s.push_str(w);
}
para += w.len() + 1;
if para > 400 {
s.push_str(".\n");
para = 0;
} else {
s.push(' ');
}
}
s
}
/// Procedural RGB image, PNG-encoded.
fn art_png(w: u32, h: u32, seed: f32) -> Vec<u8> {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0);
px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]);
}
}
designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png()
}
/// Parameters of the synthetic stress document.
pub struct Spec {
pub pages: usize,
pub frames_per_page: usize,
pub frames_per_story: usize,
pub chars: usize,
pub images: usize,
pub assets: usize,
}
impl Default for Spec {
fn default() -> Self {
Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 }
}
}
/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text
/// frames, and a strip of placed images (one with a drop shadow per spread).
pub fn synthetic(spec: &Spec) -> Result<Document, String> {
let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() });
let lid = d.default_layer();
d.swatches.push(Swatch {
name: "Perf Gradient".into(),
value: SwatchValue::Gradient {
gradient: Gradient {
kind: GradientKind::Linear,
stops: vec![GradientStop::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))],
},
},
locked: false,
named: true,
hidden: false,
});
let mut rng = Rng(0x5eed);
let (aw, ah) = (800u32, 600u32);
let assets: Vec<AssetId> = (0..spec.assets.max(1))
.map(|k| {
let id = AssetId(d.alloc());
let data = art_png(aw, ah, k as f32 * 0.37);
d.assets.insert(
id,
Arc::new(Asset {
id,
name: format!("art-{k}.png"),
mime: "image/png".into(),
data: Arc::new(data),
pixels: Some((aw, ah)),
..Default::default()
}),
);
id
})
.collect();
let total_frames = spec.pages * spec.frames_per_page;
let stories = total_frames.div_ceil(spec.frames_per_story.max(1));
let per_story = spec.chars / stories.max(1);
let rows = spec.frames_per_page.div_ceil(2);
let (top, strip) = (48.0, 130.0);
let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64;
let mut prev: Option<ItemId> = None;
let mut n_frame = 0usize;
let mut placed_images = 0usize;
for abs in 0..spec.pages {
let Some((si, pi)) = d.page_loc(abs) else { break };
let sr = SpreadRef::Doc(si);
let x0 = d.spreads[si].pages[pi].x;
// Gradient band.
let id = ItemId(d.alloc());
let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0)));
band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() };
band.fill.gradient_angle = Some(rng.next() * 90.0);
d.insert_item(sr, band, None).map_err(|e| e.to_string())?;
// Text frames.
for k in 0..spec.frames_per_page {
let (c, r) = (k % 2, k / 2);
let fx = x0 + 36.0 + c as f64 * 280.0;
let fy = top + r as f64 * row_h;
let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0);
let first = n_frame.is_multiple_of(spec.frames_per_story.max(1));
let text = if first { filler(&mut rng, per_story) } else { String::new() };
let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).map_err(|e| e.to_string())?;
if let (false, Some(p)) = (first, prev) {
d.thread(p, fid).map_err(|e| e.to_string())?;
}
prev = Some(fid);
n_frame += 1;
}
// Images: spread the requested count evenly over the pages.
let want = (spec.images * (abs + 1)) / spec.pages - placed_images;
for k in 0..want {
let w = 540.0 / want as f64;
let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0);
let asset = assets[(placed_images + k) % assets.len()];
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
let (nw, nh) = (aw as f64, ah as f64);
let s = (r.width() / nw).max(r.height() / nh);
it.content = Content::Graphic(Graphic {
layer_options: None,
asset,
size: (nw, nh),
xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s),
clip: None,
auto_fit: designcraft_doc::Fitting::FillProportionally,
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
if k == 0 && pi == 0 {
it.effects.drop_shadow.on = true;
}
d.insert_item(sr, it, None).map_err(|e| e.to_string())?;
}
placed_images += want;
}
Ok(d)
}
+3
View File
@@ -2,3 +2,6 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
+16 -7
View File
@@ -2255,19 +2255,28 @@ fn mark_keep_violations(doc: &Document, story: &Story, out: &mut ComposedStory)
const MAX_COLUMN_RULE_WEIGHT: f64 = 1000.0;
/// Text Frame Options › Column Rules: one bar per gutter, `column_rule_weight` wide, centred
/// between the facing edges of the two columns and moved by `column_rule_offset`, from the
/// columns' top to their bottom (the text area, inside the insets) shortened by the rule's top
/// and bottom insets. Works in the composed space, so right-to-left and vertical frames (whose
/// columns are laid out in the turned box) get their rules between the columns too.
/// between the facing edges of the two columns and moved by `column_rule_offset`, from the top of
/// the frame's top line (its ascent) to the bottom of its bottom line (its descent), as InDesign
/// draws them, shortened by the rule's top and bottom insets (a negative inset extends it). Works
/// in the composed space, so right-to-left and vertical frames (whose columns are laid out in the
/// turned box) get their rules between the columns too.
///
/// A paragraph that spans columns interrupts the rule: the bar stops at the top of its first line
/// and resumes below its last line. Nothing is drawn for a single column or a weight that is not a
/// positive finite number.
/// and resumes below its last line. Nothing is drawn for a frame without lines, a single column or
/// a weight that is not a positive finite number.
pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptions) -> Vec<Rect> {
let weight = opts.column_rule_weight;
if !weight.is_finite() || weight <= 0.0 || columns.len() < 2 {
return Vec::new();
}
let (text_top, text_bottom) = lines
.iter()
.map(|l| (l.baseline - l.ascent, l.baseline + l.descent))
.filter(|(t, z)| t.is_finite() && z.is_finite())
.fold((f64::INFINITY, f64::NEG_INFINITY), |(t, z), (lt, lz)| (t.min(lt), z.max(lz)));
if text_bottom < text_top {
return Vec::new();
}
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
let (offset, top_inset, bottom_inset) =
(finite(opts.column_rule_offset), finite(opts.column_rule_top_inset), finite(opts.column_rule_bottom_inset));
@@ -2279,7 +2288,7 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio
for pair in cols.windows(2) {
let [a, b] = [pair[0], pair[1]];
let cx = (a.x1 + b.x0) / 2.0;
let (top, bottom) = (a.y0.max(b.y0) + top_inset, a.y1.min(b.y1) - bottom_inset);
let (top, bottom) = (text_top + top_inset, text_bottom - bottom_inset);
if bottom <= top {
continue;
}
+60 -13
View File
@@ -5733,14 +5733,27 @@ fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document,
(d, fid, rules)
}
/// Returns the top of the first frame's top line (its ascent) and the bottom of its bottom line
/// (its descent).
fn text_extent(d: &Document, fid: ItemId) -> (f64, f64) {
let sid = d.item(fid).unwrap().text_frame().unwrap().story;
let cs = compose_story(d, sid, &ComposeOptions::default());
let lines = &cs.frames[0].lines;
assert!(!lines.is_empty());
let top = lines.iter().map(|l| l.baseline - l.ascent).fold(f64::INFINITY, f64::min);
let bottom = lines.iter().map(|l| l.baseline + l.descent).fold(f64::NEG_INFINITY, f64::max);
(top, bottom)
}
#[test]
fn column_rules_sit_in_the_gutters_inside_the_insets() {
// Two columns in 0..300 with 6 pt side insets: the gutter is centred on 150.
let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {});
let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {});
assert_eq!(rules.len(), 1, "{rules:?}");
let r = rules[0];
assert!((r.center().x - 150.0).abs() < 1e-9 && (r.width() - 2.0).abs() < 1e-9, "{r:?}");
assert!((r.y0 - 10.0).abs() < 1e-9 && (r.y1 - 180.0).abs() < 1e-9, "{r:?}");
let (top, bottom) = text_extent(&d, fid);
assert!((r.y0 - top).abs() < 1e-9 && (r.y1 - bottom).abs() < 1e-9, "{r:?} for the text {top}..{bottom}");
// Three columns: one rule per gutter, each centred between its two columns.
let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 3, |_, _| {});
assert_eq!(rules.len(), 2, "{rules:?}");
@@ -5759,16 +5772,36 @@ fn column_rules_sit_in_the_gutters_inside_the_insets() {
assert!(rules.is_empty());
}
/// Column rules run from the top line's ascent to the bottom line's descent: text that fills the
/// top of a tall frame gets rules down to its bottom line, not to the bottom of the columns.
#[test]
fn column_rules_run_from_the_top_lines_ascent_to_the_bottom_lines_descent() {
let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 600.0), 2, |_, _| {});
let (top, bottom) = text_extent(&d, fid);
assert!(bottom < 300.0, "the text fills the top of the 10..580 columns: {top}..{bottom}");
assert_eq!(rules.len(), 1, "{rules:?}");
assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}");
// Without lines there is no rule.
let cols = [Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 200.0, 50.0)];
assert!(column_rule_rects(&cols, &[], &TextFrameOptions { column_rule_weight: 1.0, ..Default::default() }).is_empty());
}
#[test]
fn column_rules_centre_on_unequal_gutters_and_ignore_bad_weights() {
let cols = [Rect::new(200.0, 0.0, 300.0, 50.0), Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 180.0, 50.0)];
// One line in the first column, from 0 to 50.
let (d, sid, _) = doc_with("Text", Rect::new(0.0, 0.0, 100.0, 50.0), ParaAttrs::default());
let mut line = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone();
(line.x0, line.x1, line.ascent, line.descent, line.baseline) = (0.0, 100.0, 40.0, 10.0, 40.0);
let lines = [line];
let weight = |w: f64| TextFrameOptions { column_rule_weight: w, ..Default::default() };
let rules = column_rule_rects(&cols, &[], &weight(1.0));
let rules = column_rule_rects(&cols, &lines, &weight(1.0));
let centres: Vec<f64> = rules.iter().map(|r| r.center().x).collect();
assert_eq!(centres, [105.0, 190.0]);
assert!(column_rule_rects(&cols, &[], &weight(f64::NAN)).is_empty());
assert!(column_rule_rects(&cols, &[], &weight(-1.0)).is_empty());
assert!((column_rule_rects(&cols, &[], &weight(1e12))[0].width() - 1000.0).abs() < 1e-9);
assert!(rules.iter().all(|r| r.y0 == 0.0 && r.y1 == 50.0), "{rules:?}");
assert!(column_rule_rects(&cols, &lines, &weight(f64::NAN)).is_empty());
assert!(column_rule_rects(&cols, &lines, &weight(-1.0)).is_empty());
assert!((column_rule_rects(&cols, &lines, &weight(1e12))[0].width() - 1000.0).abs() < 1e-9);
}
#[test]
@@ -5781,9 +5814,12 @@ fn column_rules_in_right_to_left_and_vertical_frames() {
// Vertical type: the columns stack top to bottom, so the rule runs across the frame.
let (d, fid, rules) = ruled("縦書き", Rect::new(100.0, 100.0, 400.0, 300.0), 2, |d, sid| d.story_mut(sid).unwrap().vertical = true);
assert_eq!(rules.len(), 1);
let (top, bottom) = text_extent(&d, fid);
assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}");
let r = d.text_xf(d.item(fid).unwrap()).transform_rect_bbox(rules[0]);
// Text area: x 106..394, y 110..280; the gutter is centred on y 195.
assert!((r.x0 - 106.0).abs() < 1e-6 && (r.x1 - 394.0).abs() < 1e-6, "{r:?}");
// Text area: x 106..394, y 110..280; the gutter is centred on y 195, and the one line of
// text runs from the right edge, so the rule is as long as that line is deep.
assert!(r.x0 > 106.0 && (r.x1 - 394.0).abs() < 2.0 && (r.width() - (bottom - top)).abs() < 1e-6, "{r:?}");
assert!((r.center().y - 195.0).abs() < 1e-6 && (r.height() - 2.0).abs() < 1e-6, "{r:?}");
}
@@ -5802,13 +5838,15 @@ fn column_rules_break_around_paragraphs_that_span_columns() {
for r in &rules {
assert!(r.y1 <= top + 1e-9 || r.y0 >= bottom - 1e-9, "rule {r:?} cuts the span {top}..{bottom}");
}
// The rule resumes below the span and runs to the bottom of the columns.
assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - 380.0).abs() < 1e-9), "{rules:?}");
// The rule resumes below the span and runs to the bottom line's descent.
let (_, text_bottom) = text_extent(&d, fid);
assert!(text_bottom < 380.0 - 1.0, "the text ends above the columns' bottom: {text_bottom}");
assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - text_bottom).abs() < 1e-9), "{rules:?}");
}
#[test]
fn column_rules_take_their_offset_insets_and_tint() {
let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| {
let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| {
let fid = d.story(sid).unwrap().frames[0];
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
o.column_rule_offset = 3.0;
@@ -5816,13 +5854,22 @@ fn column_rules_take_their_offset_insets_and_tint() {
o.column_rule_bottom_inset = 25.0;
o.column_rule_tint = 0.4;
});
// Gutter centre 150 moved 3 pt; columns 10..180 shortened to 25..155.
// Gutter centre 150 moved 3 pt; the text's extent shortened by 15 at the top and 25 at the
// bottom.
let (top, bottom) = text_extent(&d, fid);
assert_eq!(rules.len(), 1, "{rules:?}");
let r = rules[0];
assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - 25.0).abs() < 1e-9 && (r.y1 - 155.0).abs() < 1e-9, "{r:?}");
assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - (top + 15.0)).abs() < 1e-9 && (r.y1 - (bottom - 25.0)).abs() < 1e-9, "{r:?}");
let sid = d.item(fid).unwrap().text_frame().unwrap().story;
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(cs.frames[0].decos.iter().any(|dc| dc.rect == r && (dc.tint - 0.4).abs() < 1e-6));
// A negative inset extends the rule past the text.
let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| {
let fid = d.story(sid).unwrap().frames[0];
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.column_rule_top_inset = -4.0;
});
let (top, bottom) = text_extent(&d, fid);
assert!(rules.len() == 1 && (rules[0].y0 - (top - 4.0)).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?}");
// Insets that meet leave no rule.
let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| {
let fid = d.story(sid).unwrap().frames[0];
+3
View File
@@ -60,6 +60,9 @@ pub(crate) struct Exporter<'a> {
/// The document's inks with its Ink Manager settings.
pub(crate) inks: craft_prepress::InkSet,
pub cache: &'a Cache,
/// Each story's composition per page it is shown on, for this export: the document doesn't
/// change while it runs, and asking the cache checks every frame of the story again.
composed: HashMap<(designcraft_doc::StoryId, Option<usize>), Arc<designcraft_compose::ComposedStory>>,
pub opts: &'a PdfOptions,
/// Current sheet's visible area (spread space) for culling.
pub clip: Rect,
+1
View File
@@ -98,6 +98,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
inks: crate::inks::ink_set(doc),
doc,
cache,
composed: HashMap::new(),
opts: &pdf_opts,
clip: Rect::ZERO,
warnings,
+1
View File
@@ -198,6 +198,7 @@ mod image_content_cache_tests {
inks: crate::inks::ink_set(doc),
doc,
cache,
composed: HashMap::new(),
opts,
clip: Rect::ZERO,
warnings: Vec::new(),
+1
View File
@@ -172,6 +172,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
inks: crate::inks::ink_set(doc),
doc,
cache,
composed: HashMap::new(),
opts,
clip: Rect::ZERO,
warnings,
+8 -2
View File
@@ -4,6 +4,12 @@ use super::pipeline::{item_exported, layer_exported};
use super::*;
impl Exporter<'_> {
/// Story `sid` composed for `page` ([`Cache::get`]), asked of the cache once per export.
fn composed(&mut self, sid: designcraft_doc::StoryId, page: Option<usize>) -> Arc<designcraft_compose::ComposedStory> {
let (doc, cache) = (self.doc, self.cache);
self.composed.entry((sid, page)).or_insert_with(|| cache.get(doc, sid, page)).clone()
}
/// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases
/// first, then the parent.
fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) {
@@ -325,7 +331,7 @@ impl Exporter<'_> {
}
}
Content::Text(tfr) => {
let cs = self.cache.get(doc, tfr.story, page);
let cs = self.composed(tfr.story, page);
if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) {
// Type on a path: the glyphs as outlines along the path.
for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) {
@@ -381,7 +387,7 @@ impl Exporter<'_> {
s.pop();
}
if let Content::Text(tfr) = &it.content {
let cs = self.cache.get(doc, tfr.story, page);
let cs = self.composed(tfr.story, page);
if let Some(ft) = cs.frame(it.id) {
self.frame_objects(s, ft, xf * doc.text_local(it), page);
}
+35 -1
View File
@@ -623,8 +623,15 @@ fn column_rules_are_drawn() {
o.inset = [0.0; 4];
o.column_rule = on;
o.column_rule_weight = 2.0;
// The rules run from the top line's ascent to the bottom line's descent, not the 264 pt
// of the columns.
let sid = *d.stories.keys().next().unwrap();
let cs = designcraft_compose::compose_story(&d, sid, &designcraft_compose::ComposeOptions::default());
let line = &cs.frames[0].lines[0];
let depth = line.ascent + line.descent;
assert!(cs.frames[0].lines.len() == 1 && depth < 264.0 - 1.0, "{depth}");
let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - 264.0).abs() < 0.01).map(|r| r.center().x).collect::<Vec<_>>()
paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - depth).abs() < 0.01).map(|r| r.center().x).collect::<Vec<_>>()
};
// 540 pt wide in three columns with 12 pt gutters: columns of 172 pt from x 36.
let xs = rules(true);
@@ -890,3 +897,30 @@ fn a_swatch_colours_a_greyscale_image() {
let (s, _) = export(&rgb, Some("PANTONE Test"));
assert!(!s.contains("PANTONE#20Test"), "a colour image keeps its colours");
}
/// The export asks for each story's composition once per page it is shown on: a parent page's
/// page number still reads its own page everywhere, and a story threaded across pages shows each
/// frame's own lines.
#[test]
fn each_page_shows_its_own_composition() {
use designcraft_doc::story::PAGE_NUMBER;
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..NewDocument::default() });
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, 300.0, 740.0), lid, &format!("Folio{PAGE_NUMBER}"), ParaFormat::default()).unwrap();
let (first, _) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 56.0), lid, "Alpha\nBravo\nCharlie", ParaFormat::default()).unwrap();
let mut prev = first;
for si in 1..3 {
let id = ItemId(d.alloc());
let it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 36.0, 300.0, 56.0)));
d.insert_item(SpreadRef::Doc(si), it, None).unwrap();
d.thread(prev, id).unwrap();
prev = id;
}
let cache = Cache::new();
let opts = PdfOptions { created: Some(0), ..PdfOptions::default() };
let bytes = export_pdf(&d, &cache, &opts).unwrap();
// Parent items first, then the page's own.
assert_eq!(extract_text(&bytes), ["Folio1Alpha", "Folio2Bravo", "Folio3Charlie"]);
assert_eq!(export_pdf(&d, &cache, &opts).unwrap(), bytes, "a warm cache exports the same bytes");
}
+4 -4
View File
@@ -38,7 +38,7 @@ fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &st
let landscape = size.0 > size.1;
ui.vertical(|ui| {
let cur = panels::properties::preset_name(size.0, size.1);
egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui(
craft_ui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui(
ui,
|ui| {
for (name, w, h) in panels::properties::PAGE_PRESETS {
@@ -105,7 +105,7 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.vertical(|ui| {
caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:"));
let label = panels::interactive::liquid_rule_label(pg.liquid);
egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui(
craft_ui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui(
ui,
|ui| {
for (r, l) in panels::interactive::LIQUID_RULES {
@@ -368,7 +368,7 @@ fn control_character(
let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
let names: Vec<String> = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
let mut pick = None;
egui::ComboBox::from_id_salt("ctl_char_style")
craft_ui::ComboBox::from_id_salt("ctl_char_style")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs)))
.width(150.0)
.show_ui(ui, |ui| {
@@ -382,7 +382,7 @@ fn control_character(
let _ = app.run("style.character.apply", json!({"name": n}));
}
let lang = chars["language"].as_str().unwrap_or("English: USA").to_string();
egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang));
});
});
+1 -1
View File
@@ -223,7 +223,7 @@ fn controls(ui: &mut egui::Ui, v: &mut View, lang: &str) {
}
ui.separator();
ui.label(tr(lang, "Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(tr(lang, v.key.label().0)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(tr(lang, v.key.label().0)).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, tr(lang, k.label().0)).clicked() {
v.key = k;
+106 -94
View File
@@ -314,7 +314,7 @@ fn epub_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
d.fields.insert("mode".into(), json!("style"));
}
let cur = d.s("style");
egui::ComboBox::from_id_salt("epub_split_style").selected_text(crate::rtl::widget(ui, &cur)).width(180.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("epub_split_style").selected_text(crate::rtl::widget(ui, &cur)).width(180.0).show_ui(ui, |ui| {
for name in &styles {
if ui.selectable_label(cur == *name, crate::rtl::widget(ui, name)).clicked() {
d.fields.insert("style".into(), json!(name));
@@ -343,7 +343,7 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
if printers.is_empty() {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "System default")).italics());
} else {
egui::ComboBox::from_id_salt("printer").selected_text(&cur).width(220.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("printer").selected_text(&cur).width(220.0).show_ui(ui, |ui| {
for p in &printers {
if ui.selectable_label(*p == cur, p).clicked() {
d.fields.insert("printer".into(), json!(p));
@@ -423,7 +423,7 @@ fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Preset:"));
let cur = d.s("preset");
egui::ComboBox::from_id_salt("pdf_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, &cur))).width(200.0).show_ui(
craft_ui::ComboBox::from_id_salt("pdf_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, &cur))).width(200.0).show_ui(
ui,
|ui| {
for p in PDF_EXPORT_PRESETS {
@@ -1508,7 +1508,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ->
setup_row(ui, rtl, |ui| {
setup_caption(ui, tr("Intent:"), label_w);
let cur = d.s("intent");
egui::ComboBox::from_id_salt("ds_intent")
craft_ui::ComboBox::from_id_salt("ds_intent")
.selected_text(crate::rtl::widget(
ui,
tr(match cur.as_str() {
@@ -1542,7 +1542,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ->
setup_row(ui, rtl, |ui| {
setup_caption(ui, tr("Binding:"), label_w);
let right_to_left = d.s("binding") == "rightToLeft";
egui::ComboBox::from_id_salt("ds_binding")
craft_ui::ComboBox::from_id_salt("ds_binding")
.selected_text(crate::rtl::widget(ui, tr(if right_to_left { "Right to Left" } else { "Left to Right" })))
.width(150.0)
.show_ui(ui, |ui| {
@@ -1563,7 +1563,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ->
setup_group(ui, |ui| {
setup_row(ui, rtl, |ui| {
crate::rtl::label(ui, egui::RichText::new(tr("Page Size:")).color(t.text_strong));
egui::ComboBox::from_id_salt("ds_preset").selected_text(crate::rtl::widget(ui, &preset)).width(260.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("ds_preset").selected_text(crate::rtl::widget(ui, &preset)).width(260.0).show_ui(ui, |ui| {
for p in designcraft_doc::build::PRESETS {
if ui.selectable_label(tr(p.name) == preset, crate::rtl::widget(ui, tr(p.name))).clicked() {
// Keep the orientation; the preset's size in the document's units.
@@ -1915,7 +1915,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "On Screen:"));
let rich = d.b("richBlack");
egui::ComboBox::from_id_salt("pref_black")
craft_ui::ComboBox::from_id_salt("pref_black")
.selected_text(crate::rtl::widget(
ui,
crate::i18n::tr(
@@ -1935,7 +1935,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Printing / Exporting:"));
let rich = d.b("richBlackOutput");
egui::ComboBox::from_id_salt("pref_black_out")
craft_ui::ComboBox::from_id_salt("pref_black_out")
.selected_text(crate::rtl::widget(
ui,
crate::i18n::tr(
@@ -1985,7 +1985,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Relative To:"));
let cur = d.s("bg.relativeTo");
egui::ComboBox::from_id_salt("bg_rel")
craft_ui::ComboBox::from_id_salt("bg_rel")
.selected_text(crate::rtl::widget(
ui,
crate::i18n::tr(&app.ui.language, if cur == "topMargin" { "Top Margin" } else { "Top of Page" }),
@@ -2066,7 +2066,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
let cur = d.s("displayQuality");
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Default View:"));
egui::ComboBox::from_id_salt("pref_dq")
craft_ui::ComboBox::from_id_salt("pref_dq")
.selected_text(match cur.as_str() {
"fast" => crate::i18n::tr(&app.ui.language, "Fast"),
"typical" => crate::i18n::tr(&app.ui.language, "Typical"),
@@ -2088,7 +2088,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
let cur = d.fields.get("rulerOrigin").and_then(Value::as_str).unwrap_or("spread").to_string();
const ORIGINS: [(&str, &str); 3] = [("spread", "Spread"), ("page", "Page"), ("spine", "Spine")];
let shown = ORIGINS.iter().find(|o| o.0 == cur).map_or("Spread", |o| o.1);
egui::ComboBox::from_id_salt("rulerOrigin")
craft_ui::ComboBox::from_id_salt("rulerOrigin")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown)))
.width(140.0)
.show_ui(ui, |ui| {
@@ -2102,7 +2102,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
for (key, label) in [("horizontalUnits", "Horizontal:"), ("verticalUnits", "Vertical:")] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
let cur: Unit = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
egui::ComboBox::from_id_salt(key)
craft_ui::ComboBox::from_id_salt(key)
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cur.label())))
.width(140.0)
.show_ui(ui, |ui| {
@@ -2137,17 +2137,18 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Interface Language")).font(semibold(12.0)));
// Each language by its own name, so the list reads the same in every interface language.
let current = crate::i18n::LANGUAGES.iter().find(|(code, _)| *code == app.ui.language).map_or("English", |(_, name)| *name);
egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui(
ui,
|ui| {
craft_ui::ComboBox::from_id_salt("pref_language")
.width(180.0)
.height(400.0)
.selected_text(crate::rtl::widget(ui, current))
.show_ui(ui, |ui| {
for (code, name) in crate::i18n::picker_languages() {
if ui.selectable_label(code == app.ui.language, crate::rtl::widget(ui, name)).clicked() {
// Applied by `show` right after this frame, which has `&mut DesignApp`.
d.fields.insert("applyLanguage".into(), json!(code));
}
}
},
);
});
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "When Scaling")).font(semibold(12.0)));
check(ui, d, "scaleStrokes", crate::i18n::tr(&app.ui.language, "Include Stroke Weight"));
@@ -2392,7 +2393,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
let cur = d.s(key);
egui::ComboBox::from_id_salt(key)
craft_ui::ComboBox::from_id_salt(key)
.selected_text(if cur.is_empty() { crate::i18n::tr(&app.ui.language, any) } else { cur.as_str() })
.width(180.0)
.show_ui(ui, |ui| {
@@ -2412,7 +2413,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:"));
let cur = d.s("objKind");
egui::ComboBox::from_id_salt("objKind")
craft_ui::ComboBox::from_id_salt("objKind")
.selected_text(if cur.is_empty() { crate::i18n::tr(&app.ui.language, "(any)") } else { cur.as_str() })
.show_ui(ui, |ui| {
for v in ["", "text", "graphic", "shape", "line", "group"] {
@@ -2461,7 +2462,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:"));
let cur = d.s("scope");
egui::ComboBox::from_id_salt("fcscope")
craft_ui::ComboBox::from_id_salt("fcscope")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur)))
.show_ui(ui, |ui| {
for v in ["document", "story", "selection"] {
@@ -2571,7 +2572,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"media" => crate::i18n::tr(&app.ui.language, "Media"),
_ => crate::i18n::tr(&app.ui.language, "Crop"),
};
egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| {
for v in designcraft_doc::PdfCrop::ALL.map(designcraft_doc::PdfCrop::name) {
if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() {
d.fields.insert("crop".into(), json!(v));
@@ -2608,7 +2609,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
let cur = d.s(key);
egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| {
for pr in profiles.iter().filter(|p| p["kind"] == kind) {
let name = pr["name"].as_str().unwrap_or("");
if ui.selectable_label(name == cur, name).clicked() {
@@ -2620,7 +2621,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:"));
let cur = d.s("intent");
egui::ComboBox::from_id_salt("ws_intent")
craft_ui::ComboBox::from_id_salt("ws_intent")
.selected_text(crate::rtl::widget(
ui,
crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())),
@@ -2731,7 +2732,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
} else {
mapped.clone()
};
egui::ComboBox::from_id_salt(("imp_map", w))
craft_ui::ComboBox::from_id_salt(("imp_map", w))
.selected_text(crate::rtl::widget(ui, &shown))
.width(170.0)
.show_ui(ui, |ui| {
@@ -2852,7 +2853,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
};
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:"));
egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(
craft_ui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(
ui,
|ui| {
for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] {
@@ -2910,7 +2911,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"vcard" => crate::i18n::tr(&app.ui.language, "Business Card"),
_ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"),
};
egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(
craft_ui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(
ui,
|ui| {
for v in ["url", "text", "sms", "email", "vcard"] {
@@ -4143,7 +4144,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Tag:"));
let cur = d.s("x.tag");
egui::ComboBox::from_id_salt("export_tag")
craft_ui::ComboBox::from_id_salt("export_tag")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur)))
.show_ui(ui, |ui| {
for t in ["[Automatic]", "p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "li", "figcaption", "aside", "div"]
@@ -4167,7 +4168,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
let alignment_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Alignment:"));
let a: designcraft_doc::Align = serde_json::from_value(cur(d, "p.align", &pv["align"])).unwrap_or_default();
egui::ComboBox::from_id_salt("psalign")
craft_ui::ComboBox::from_id_salt("psalign")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, a.label())))
.width(240.0)
.show_ui(ui, |ui| {
@@ -4201,7 +4202,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
let grid_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align to Grid:"));
let g: GridAlign = serde_json::from_value(cur(d, "p.gridAlign", &pv["gridAlign"])).unwrap_or_default();
let shown = GRID_ALIGN.iter().find(|o| o.0 == g).map_or("", |o| o.1);
egui::ComboBox::from_id_salt("psgridalign")
craft_ui::ComboBox::from_id_salt("psgridalign")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown)))
.width(240.0)
.show_ui(ui, |ui| {
@@ -4265,7 +4266,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
.map(str::to_string)
.or(style.based_on.clone())
.unwrap_or_else(|| "[No Paragraph Style]".into());
let combo = egui::ComboBox::from_id_salt("psbased")
let combo = craft_ui::ComboBox::from_id_salt("psbased")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &based)))
.width(220.0)
.show_ui(ui, |ui| {
@@ -4372,7 +4373,7 @@ fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog
d.fields.insert("replaceWith".into(), json!(designcraft_doc::BASIC_PARAGRAPH));
}
let replace = d.s("replaceWith");
egui::ComboBox::from_id_salt("del_para_replace")
craft_ui::ComboBox::from_id_salt("del_para_replace")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
.width(260.0)
.show_ui(ui, |ui| {
@@ -4506,7 +4507,7 @@ fn para_measure(ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, unit:
fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, opts: &[(&str, &str)]) {
let cur = para_value(d, pv, key);
let shown = opts.iter().find(|o| cur == o.0).map_or("", |o| crate::i18n::tr(lang, o.1));
egui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("pschoice", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
for (v, label) in opts {
if ui.selectable_label(cur == *v, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
para_set(d, key, json!(v));
@@ -4543,7 +4544,7 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s
/// picked.
fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option<String> {
let mut picked = None;
egui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| {
for w in doc.swatches.iter().filter(|w| !w.hidden) {
let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0);
ui.horizontal(|ui| {
@@ -4570,7 +4571,7 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d
StrokeType::Style { name } => name.clone(),
k => crate::i18n::tr(lang, k.label()).to_string(),
};
egui::ComboBox::from_id_salt("psbordertype").selected_text(crate::rtl::widget(ui, shown)).width(160.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("psbordertype").selected_text(crate::rtl::widget(ui, shown)).width(160.0).show_ui(ui, |ui| {
for k in BORDER_TYPES {
let ty = if k == "dashed" { json!({"kind": k, "pattern": []}) } else { json!({"kind": k}) };
let Ok(t) = serde_json::from_value::<StrokeType>(ty.clone()) else { continue };
@@ -4602,7 +4603,7 @@ fn para_corners(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key:
c.size = if v.is_finite() { v.clamp(0.0, 10_000.0) } else { 0.0 };
changed = true;
}
egui::ComboBox::from_id_salt(("pscornershape", key, i))
craft_ui::ComboBox::from_id_salt(("pscornershape", key, i))
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, c.shape.label())))
.width(130.0)
.show_ui(ui, |ui| {
@@ -4761,7 +4762,7 @@ fn drop_cap_fields(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cn
crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:"));
let style = field_or(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string();
let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style };
egui::ComboBox::from_id_salt("ps_dropcap_style")
craft_ui::ComboBox::from_id_salt("ps_dropcap_style")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(160.0)
.show_ui(ui, |ui| {
@@ -4812,19 +4813,19 @@ fn nested_style_rows(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value,
fn nested_style_row(lang: &str, ui: &mut egui::Ui, ns: &mut Value, i: usize, cnames: &[String]) -> (bool, bool) {
let mut changed = false;
let style = ns["style"].as_str().unwrap_or("[None]").to_string();
egui::ComboBox::from_id_salt(("ns_style", i)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))).width(110.0).show_ui(
ui,
|ui| {
craft_ui::ComboBox::from_id_salt(("ns_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(110.0)
.show_ui(ui, |ui| {
for c in cnames {
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
ns["style"] = json!(c);
changed = true;
}
}
},
);
});
let through = ns["through"].as_bool().unwrap_or(true);
egui::ComboBox::from_id_salt(("ns_thr", i))
craft_ui::ComboBox::from_id_salt(("ns_thr", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" })))
.width(70.0)
.show_ui(ui, |ui| {
@@ -4846,7 +4847,7 @@ fn nested_style_row(lang: &str, ui: &mut egui::Ui, ns: &mut Value, i: usize, cna
count_value(ui, ns, &mut changed);
}
let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
egui::ComboBox::from_id_salt(("ns_until", i))
craft_ui::ComboBox::from_id_salt(("ns_until", i))
.selected_text(crate::rtl::widget(
ui,
if crate::i18n::category_first(lang) { format!("{}:", crate::i18n::tr(lang, &kind)) } else { crate::i18n::tr(lang, &kind).to_owned() },
@@ -4910,17 +4911,17 @@ fn nested_line_style_rows(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Va
fn nested_line_style_row(lang: &str, ui: &mut egui::Ui, l: &mut Value, i: usize, cnames: &[String]) -> (bool, bool) {
let mut changed = false;
let style = l["style"].as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(("nls_style", i)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))).width(130.0).show_ui(
ui,
|ui| {
craft_ui::ComboBox::from_id_salt(("nls_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(130.0)
.show_ui(ui, |ui| {
for c in cnames {
if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() {
l["style"] = json!(c);
changed = true;
}
}
},
);
});
crate::rtl::label(
ui,
if crate::i18n::category_first(lang) { format!("{}:", crate::i18n::tr(lang, "lines")) } else { crate::i18n::tr(lang, "for").to_owned() },
@@ -4953,7 +4954,7 @@ fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(lang, "Apply Style:"));
let style = g["style"].as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(("gs_style", i))
craft_ui::ComboBox::from_id_salt(("gs_style", i))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style)))
.width(110.0)
.show_ui(ui, |ui| {
@@ -5068,7 +5069,7 @@ fn span_columns(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc:
// "Span Columns" names both the section and this option, which some languages word differently.
let kind_label = |k: &'static str| -> &'static str { crate::i18n::tr_context(lang, k, "paragraphLayout") };
let shown = kinds.iter().find(|k| same(&k.1, &cur)).map_or("", |k| kind_label(k.0));
egui::ComboBox::from_id_salt("psspankind").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("psspankind").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
for (label, v) in kinds {
if ui.selectable_label(same(&v, &cur), crate::rtl::widget(ui, kind_label(label))).clicked() && !same(&v, &cur) {
next = Some(v);
@@ -5081,7 +5082,7 @@ fn span_columns(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc:
SpanColumns::Span(n) => {
crate::rtl::label(ui, crate::i18n::tr(lang, "Columns:"));
let shown = if n == 0 { crate::i18n::tr(lang, "All").to_string() } else { n.to_string() };
egui::ComboBox::from_id_salt("psspann").selected_text(crate::rtl::widget(ui, shown)).width(80.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("psspann").selected_text(crate::rtl::widget(ui, shown)).width(80.0).show_ui(ui, |ui| {
if ui.selectable_label(n == 0, crate::rtl::widget(ui, crate::i18n::tr(lang, "All"))).clicked() {
next = Some(SpanColumns::Span(0));
}
@@ -5174,9 +5175,10 @@ impl ListUi<'_> {
/// A character style for a bullet or number (`key`), [None] first.
fn list_char_style(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &str, styles: &[String]) {
let cur = para_value(d, pv, key).as_str().unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
egui::ComboBox::from_id_salt(("pslistcs", key)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &cur))).width(200.0).show_ui(
ui,
|ui| {
craft_ui::ComboBox::from_id_salt(("pslistcs", key))
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &cur)))
.width(200.0)
.show_ui(ui, |ui| {
for n in std::iter::once(designcraft_doc::NO_CHAR_STYLE)
.chain(styles.iter().map(String::as_str).filter(|n| *n != designcraft_doc::NO_CHAR_STYLE))
{
@@ -5184,8 +5186,7 @@ fn list_char_style(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, ke
para_set(d, key, json!(n));
}
}
},
);
});
}
/// The bullet character: common bullets, a field, and the font's glyphs in a grid to pick from.
@@ -5244,7 +5245,7 @@ fn bullet_font(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, lu: &L
let font = para_value(d, pv, "bulletFont").as_str().unwrap_or("").to_string();
let inherit = crate::i18n::tr(lang, "(Paragraph Font)");
crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:"));
egui::ComboBox::from_id_salt("psbulletfont").selected_text(if font.is_empty() { inherit } else { font.as_str() }).width(200.0).show_ui(
craft_ui::ComboBox::from_id_salt("psbulletfont").selected_text(if font.is_empty() { inherit } else { font.as_str() }).width(200.0).show_ui(
ui,
|ui| {
if ui.selectable_label(font.is_empty(), inherit).clicked() {
@@ -5263,7 +5264,7 @@ fn bullet_font(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, lu: &L
crate::rtl::label(ui, crate::i18n::tr(lang, "Font Style:"));
let style = para_value(d, pv, "bulletFontStyle").as_str().unwrap_or("").to_string();
ui.add_enabled_ui(!font.is_empty(), |ui| {
egui::ComboBox::from_id_salt("psbulletstyle").selected_text(style.as_str()).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("psbulletstyle").selected_text(style.as_str()).width(200.0).show_ui(ui, |ui| {
for st in lu.fonts.styles(&font) {
if ui.selectable_label(st == style, &st).clicked() {
para_set(d, "bulletFontStyle", json!(st));
@@ -5306,7 +5307,7 @@ fn bullets_and_numbering(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Val
let list = para_value(d, pv, "listName").as_str().unwrap_or("").to_string();
// "" is the story's own list.
let name = |n: &str| if n.is_empty() { crate::i18n::tr(lang, "[Default]").to_string() } else { n.to_string() };
egui::ComboBox::from_id_salt("pslist").selected_text(name(&list)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("pslist").selected_text(name(&list)).width(200.0).show_ui(ui, |ui| {
for n in std::iter::once("").chain(doc.settings.lists.iter().map(|l| l.name.as_str())) {
if ui.selectable_label(n == list, name(n)).clicked() {
para_set(d, "listName", json!(n));
@@ -5325,7 +5326,7 @@ fn bullets_and_numbering(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Val
let start = para_value(d, pv, "startAt").as_u64();
ui.horizontal(|ui| {
let shown = crate::i18n::tr(lang, if start.is_some() { "Start At" } else { "Continue from Previous Number" });
egui::ComboBox::from_id_salt("psmode").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("psmode").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
if ui
.selectable_label(start.is_none(), crate::rtl::widget(ui, crate::i18n::tr(lang, "Continue from Previous Number")))
.clicked()
@@ -5352,16 +5353,16 @@ fn bullets_and_numbering(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Val
para_check(ui, d, pv, "restartNumbers", crate::i18n::tr(lang, "Restart Numbers at This Level After:"));
let after = para_value(d, pv, "restartAfterLevel").as_u64().unwrap_or(0) as u32;
let label = |n: u32| if n == 0 { crate::i18n::tr(lang, "Any Previous Level").to_string() } else { n.to_string() };
egui::ComboBox::from_id_salt("psrestartafter").selected_text(crate::rtl::widget(ui, label(after))).width(140.0).show_ui(
ui,
|ui| {
craft_ui::ComboBox::from_id_salt("psrestartafter")
.selected_text(crate::rtl::widget(ui, label(after)))
.width(140.0)
.show_ui(ui, |ui| {
for n in 0..level {
if ui.selectable_label(n == after, crate::rtl::widget(ui, label(n))).clicked() {
para_set(d, "restartAfterLevel", json!(n));
}
}
},
);
});
});
});
ui.end_row();
@@ -5497,7 +5498,7 @@ fn character_style_general(
text_field_named(ui, d, "rename", 220.0, crate::i18n::tr(lang, "Style Name"));
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Based On:"));
let combo = egui::ComboBox::from_id_salt("csbased")
let combo = craft_ui::ComboBox::from_id_salt("csbased")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, based)))
.width(220.0)
.show_ui(ui, |ui| {
@@ -5656,7 +5657,7 @@ fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, su
fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) {
let cur = cf.get(d, key);
let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1));
egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() {
cf.set(d, key, Value::Null);
}
@@ -5703,22 +5704,29 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:"));
let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string();
let shown = if fam.is_empty() { "—".to_string() } else { crate::panels::font_label(app, &menu, &fam) };
egui::ComboBox::from_id_salt("psfam")
let picked = craft_ui::ComboBox::from_id_salt("psfam")
.selected_text(shown)
.width(200.0)
.height(440.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show_ui(ui, |ui| {
if cf.sparse && ui.selectable_label(fam.is_empty(), " ").clicked() {
cf.set(d, "fontFamily", Value::Null);
}
if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) {
cf.set(d, "fontFamily", json!(f.family));
if let Some(style) = f.style {
cf.set(d, "fontStyle", json!(style));
.show_ui_with_header(
ui,
|ui| {
if cf.sparse && ui.selectable_label(fam.is_empty(), " ").clicked() {
cf.set(d, "fontFamily", Value::Null);
}
}
});
crate::panels::font_menu_header(app, ui, &menu, &fam, 200.0)
},
crate::panels::font_menu_list,
)
.inner
.flatten();
if let Some(f) = picked {
cf.set(d, "fontFamily", json!(f.family));
if let Some(style) = f.style {
cf.set(d, "fontStyle", json!(style));
}
}
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Font Style:"));
let sty = cf.get(d, "fontStyle").as_str().unwrap_or("").to_string();
@@ -5729,7 +5737,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
cf.set(d, "fontStyle", if s.trim().is_empty() { Value::Null } else { json!(s) });
}
} else {
egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| {
if cf.sparse && ui.selectable_label(sty.is_empty(), " ").clicked() {
cf.set(d, "fontStyle", Value::Null);
}
@@ -5837,7 +5845,7 @@ fn advanced_character_formats(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf:
let labels: Vec<String> = crate::panels::properties::DIGIT_OPTIONS.iter().map(|o| crate::panels::properties::digit_label(lang, *o)).collect();
let cur = cf.get(d, "digits");
let shown = crate::panels::properties::DIGIT_OPTIONS.iter().position(|o| json!(o.0) == cur).map_or(String::new(), |i| labels[i].clone());
egui::ComboBox::from_id_salt(("cs", "digits")).selected_text(crate::rtl::widget(ui, &shown)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("cs", "digits")).selected_text(crate::rtl::widget(ui, &shown)).width(200.0).show_ui(ui, |ui| {
if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() {
cf.set(d, "digits", Value::Null);
}
@@ -5913,14 +5921,18 @@ fn open_type_features(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFi
crate::rtl::label(ui, crate::i18n::tr(lang, "Figure Style:"));
let fig = otf::figures(&list);
let shown = otf::FIGURES.iter().find(|f| f.0 == fig).map_or("", |f| f.1);
egui::ComboBox::from_id_salt("csfig").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(180.0).show_ui(ui, |ui| {
for (id, label, ..) in otf::FIGURES {
if ui.selectable_label(fig == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() && otf::set_figures(&mut list, id)
{
changed = true;
craft_ui::ComboBox::from_id_salt("csfig").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(180.0).show_ui(
ui,
|ui| {
for (id, label, ..) in otf::FIGURES {
if ui.selectable_label(fig == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked()
&& otf::set_figures(&mut list, id)
{
changed = true;
}
}
}
});
},
);
});
ui.add_space(4.0);
crate::rtl::label(ui, crate::i18n::tr(lang, "Stylistic Sets"));
@@ -5995,7 +6007,7 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog
d.fields.insert("replaceWith".into(), json!(designcraft_doc::NO_CHAR_STYLE));
}
let replace = d.s("replaceWith");
egui::ComboBox::from_id_salt("del_char_replace")
craft_ui::ComboBox::from_id_salt("del_char_replace")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
.width(260.0)
.show_ui(ui, |ui| {
@@ -6010,7 +6022,7 @@ fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog
fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) {
let cur = d.s(key);
let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string();
egui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(key).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| {
for (v, label) in opts {
if ui.selectable_label(cur == *v, crate::rtl::widget(ui, *label)).clicked() {
d.fields.insert(key.into(), json!(v));
@@ -6351,7 +6363,7 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: &
};
ui.horizontal(|ui| {
let shown = RULES.iter().find(|r| r.0 == which).map_or(which, |r| r.1);
egui::ComboBox::from_id_salt("rule_which").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(130.0).show_ui(
craft_ui::ComboBox::from_id_salt("rule_which").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(130.0).show_ui(
ui,
|ui| {
for (v, label) in RULES {
@@ -6387,7 +6399,7 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: &
}
crate::rtl::label(ui, crate::i18n::tr(lang, "Width:"));
let column = rule["columnWidth"].as_bool().unwrap_or(true);
egui::ComboBox::from_id_salt(("rule_width", which))
craft_ui::ComboBox::from_id_salt(("rule_width", which))
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if column { "Column" } else { "Text" })))
.width(130.0)
.show_ui(ui, |ui| {
@@ -6794,7 +6806,7 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value {
fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool {
let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() };
let mut changed = false;
egui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| {
if ui.selectable_label(cur.is_empty(), auto).clicked() {
cur.clear();
changed = true;
@@ -6885,7 +6897,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
crate::rtl::label(ui, tr("Page Number:"));
let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")];
let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l);
egui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| {
for (v, l) in choices {
if ui.selectable_label(r.page_number == v, tr(l)).clicked() {
r.page_number = v.into();
@@ -6904,7 +6916,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order"));
ui.end_row();
crate::rtl::label(ui, tr("Level:"));
egui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| {
for l in 1..=9u8 {
if ui.selectable_label(r.level == l, l.to_string()).clicked() {
r.level = l;
+6 -7
View File
@@ -130,7 +130,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
if kind == "color" {
caption(ui, lang, "Color Type:");
let cur = d.s("colorType");
let combo = egui::ComboBox::from_id_salt("swatch_color_type")
let combo = craft_ui::ComboBox::from_id_salt("swatch_color_type")
.selected_text(crate::rtl::widget(ui, tr(lang, if cur == "spot" { "Spot" } else { "Process" })))
.show_ui(ui, |ui| {
for (v, l) in [("process", "Process"), ("spot", "Spot")] {
@@ -149,9 +149,9 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
"gray" => "Gray",
_ => "CMYK",
};
let combo = egui::ComboBox::from_id_salt("swatch_color_mode").selected_text(crate::rtl::widget(ui, tr(lang, label(&cur)))).show_ui(
ui,
|ui| {
let combo = craft_ui::ComboBox::from_id_salt("swatch_color_mode")
.selected_text(crate::rtl::widget(ui, tr(lang, label(&cur))))
.show_ui(ui, |ui| {
for m in ["cmyk", "rgb", "gray"] {
if ui.selectable_label(cur == m, crate::rtl::widget(ui, tr(lang, label(m)))).clicked()
&& cur != m
@@ -160,8 +160,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
set_color(&mut d.fields, c, Some(m));
}
}
},
);
});
let _ = crate::widgets::name_combo(combo, tr(lang, "Color Mode"));
ui.end_row();
match d.s("mode").as_str() {
@@ -283,7 +282,7 @@ pub fn layer_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.end_row();
caption(ui, lang, "Color:");
ui.horizontal(|ui| {
let combo = egui::ComboBox::from_id_salt("layer_color")
let combo = craft_ui::ComboBox::from_id_salt("layer_color")
.selected_text(crate::rtl::widget(ui, tr(lang, named.unwrap_or("Custom"))))
.width(140.0)
.show_ui(ui, |ui| {
+29 -22
View File
@@ -143,33 +143,40 @@ fn font_fields(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog, cf: &Char
};
crate::rtl::label(ui, tr(lang, "Font Family:"));
ui.add_enabled_ui(enabled, |ui| {
egui::ComboBox::from_id_salt(("cjkfam", fam_key))
let picked = craft_ui::ComboBox::from_id_salt(("cjkfam", fam_key))
.selected_text(shown)
.width(200.0)
.height(440.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show_ui(ui, |ui| {
if cf.sparse && ui.selectable_label(raw.is_null(), " ").clicked() {
cf.set(d, fam_key, Value::Null);
cf.set(d, style_key, Value::Null);
}
if ui.selectable_label(!raw.is_null() && fam.is_empty(), crate::rtl::widget(ui, parent.as_str())).clicked() {
cf.set(d, fam_key, json!(""));
cf.set(d, style_key, json!(""));
}
if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) {
cf.set(d, fam_key, json!(f.family));
if let Some(style) = f.style {
cf.set(d, style_key, json!(style));
.show_ui_with_header(
ui,
|ui| {
if cf.sparse && ui.selectable_label(raw.is_null(), " ").clicked() {
cf.set(d, fam_key, Value::Null);
cf.set(d, style_key, Value::Null);
}
}
});
if ui.selectable_label(!raw.is_null() && fam.is_empty(), crate::rtl::widget(ui, parent.as_str())).clicked() {
cf.set(d, fam_key, json!(""));
cf.set(d, style_key, json!(""));
}
crate::panels::font_menu_header(app, ui, &menu, &fam, 200.0)
},
crate::panels::font_menu_list,
)
.inner
.flatten();
if let Some(f) = picked {
cf.set(d, fam_key, json!(f.family));
if let Some(style) = f.style {
cf.set(d, style_key, json!(style));
}
}
});
ui.end_row();
crate::rtl::label(ui, tr(lang, "Font Style:"));
let sty = cf.get(d, style_key).as_str().unwrap_or("").to_string();
ui.add_enabled_ui(enabled && !fam.is_empty(), |ui| {
egui::ComboBox::from_id_salt(("cjksty", style_key)).selected_text(&sty).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("cjksty", style_key)).selected_text(&sty).width(200.0).show_ui(ui, |ui| {
for s in fonts.styles(&fam) {
if ui.selectable_label(s == sty, &s).clicked() {
cf.set(d, style_key, json!(s));
@@ -527,7 +534,7 @@ fn kenten(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharField
Some(false) => tr(lang, "None").to_string(),
Some(true) => tr(lang, kenten_label(kind)).to_string(),
};
egui::ComboBox::from_id_salt("cjkkententype").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("cjkkententype").selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| {
if cf.sparse && ui.selectable_label(on.is_none(), " ").clicked() {
for key in ["kenten", "kentenKind", "kentenCharacter"] {
cf.set(d, key, Value::Null);
@@ -654,7 +661,7 @@ fn para_value(d: &Dialog, pv: &Value, key: &str) -> Value {
/// A paragraph attribute from `opts` (value, label); `current` is the value shown.
fn para_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, current: &str, opts: &[(&str, &str)]) {
let shown = opts.iter().find(|o| o.0 == current).map_or("", |o| crate::i18n::tr(lang, o.1));
egui::ComboBox::from_id_salt(("cjkp", key)).selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("cjkp", key)).selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
for (v, label) in opts {
if ui.selectable_label(*v == current, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() {
d.fields.insert(format!("p.{key}"), json!(v));
@@ -731,7 +738,7 @@ fn japanese_composition(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &Char
// `null`: no set given, so the composer's default rules apply.
let name = para_value(d, pv, "kinsoku").get("name").and_then(Value::as_str).map(str::to_string);
let shown = name.as_deref().map_or_else(|| crate::i18n::tr(lang, "Default Rules").to_string(), |n| kinsoku_label(lang, n));
egui::ComboBox::from_id_salt("cjkkinsoku").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("cjkkinsoku").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
// `style.paragraph.edit` takes `null` as the default rules, also over a Based On set.
if ui.selectable_label(name.is_none(), crate::rtl::widget(ui, crate::i18n::tr(lang, "Default Rules"))).clicked() {
d.fields.insert("p.kinsoku".into(), Value::Null);
@@ -780,7 +787,7 @@ fn japanese_composition(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &Char
let sets = mojikumi_sets(doc, &current);
let label = |l: &str| if l.is_empty() { crate::i18n::tr(lang, "None").to_string() } else { l.to_string() };
let shown = sets.iter().find(|s| s.0 == current).map_or_else(String::new, |s| label(s.1.as_str()));
egui::ComboBox::from_id_salt("cjkmojikumi").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("cjkmojikumi").selected_text(crate::rtl::widget(ui, shown)).width(220.0).show_ui(ui, |ui| {
for (v, l) in &sets {
if ui.selectable_label(*v == current, crate::rtl::widget(ui, label(l.as_str()))).clicked() {
d.fields.insert("p.mojikumi".into(), json!(v));
@@ -834,7 +841,7 @@ fn grid_settings(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) {
grid("cjkgrid").show(ui, |ui| {
crate::rtl::label(ui, tr(lang, "Grid Alignment:"));
let shown = if on { REFERENCES.iter().find(|r| r.0 == reference).map_or("", |r| r.1) } else { "None" };
egui::ComboBox::from_id_salt("cjkgridalign").selected_text(crate::rtl::widget(ui, tr(lang, shown))).width(220.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("cjkgridalign").selected_text(crate::rtl::widget(ui, tr(lang, shown))).width(220.0).show_ui(ui, |ui| {
if ui.selectable_label(!on, crate::rtl::widget(ui, tr(lang, "None"))).clicked() {
set(d, GridAlign::None);
}
+8 -8
View File
@@ -250,7 +250,7 @@ fn swatch(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) {
.session
.active()
.map_or_else(Vec::new, |st| st.doc.swatches.iter().filter(|w| !w.hidden || w.name == cur).map(|w| w.name.clone()).collect());
egui::ComboBox::from_id_salt(("fx_swatch", path.join("."))).selected_text(&cur).width(150.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("fx_swatch", path.join("."))).selected_text(&cur).width(150.0).show_ui(ui, |ui| {
for n in names {
if ui.selectable_label(n == cur, &n).clicked() {
set(d, path, json!(n));
@@ -299,7 +299,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
grid("t").show(ui, |ui| {
row(ui, "Mode:");
let cur = d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string();
egui::ComboBox::from_id_salt("fx_blend").selected_text(tr(&cur)).width(150.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("fx_blend").selected_text(tr(&cur)).width(150.0).show_ui(ui, |ui| {
for m in designcraft_color::BlendMode::ALL {
if ui.selectable_label(m.label() == cur, tr(m.label())).clicked() {
d.fields.insert("blend".into(), json!(m.label()));
@@ -367,7 +367,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
serde_json::to_value(m).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
};
let shown = designcraft_color::BlendMode::ALL.iter().find(|m| label(m) == cur).map_or("Screen", |m| m.label());
egui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("fx_glow_mode").selected_text(tr(shown)).width(150.0).show_ui(ui, |ui| {
for m in designcraft_color::BlendMode::ALL {
if ui.selectable_label(label(&m) == cur, tr(m.label())).clicked() {
set(d, &["innerGlow", "mode"], json!(label(&m)));
@@ -395,7 +395,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
ui.end_row();
row(ui, "Technique:");
let cur = d.fields.get("fx").and_then(|f| f["innerGlow"]["technique"].as_str()).unwrap_or("softer").to_string();
egui::ComboBox::from_id_salt("fx_technique").selected_text(tr(if cur == "precise" { "Precise" } else { "Softer" })).show_ui(
craft_ui::ComboBox::from_id_salt("fx_technique").selected_text(tr(if cur == "precise" { "Precise" } else { "Softer" })).show_ui(
ui,
|ui| {
for (v, l) in [("softer", "Softer"), ("precise", "Precise")] {
@@ -409,7 +409,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
row(ui, "Source:");
let mut center = d.fields.get("fx").is_some_and(|f| f["innerGlow"]["center"].as_bool().unwrap_or(false));
let label = |c: bool| tr(if c { "Center" } else { "Edge" });
egui::ComboBox::from_id_salt("fx_source").selected_text(label(center)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("fx_source").selected_text(label(center)).show_ui(ui, |ui| {
for c in [false, true] {
if ui.selectable_value(&mut center, c, label(c)).clicked() {
set(d, &["innerGlow", "center"], json!(c));
@@ -490,7 +490,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
"rounded" => "Rounded",
_ => "Diffused",
};
egui::ComboBox::from_id_salt("fx_corners").selected_text(tr(shown)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("fx_corners").selected_text(tr(shown)).show_ui(ui, |ui| {
for (v, l) in [("sharp", "Sharp"), ("rounded", "Rounded"), ("diffused", "Diffused")] {
if ui.selectable_label(cur == v, tr(l)).clicked() {
set(d, &["featherOptions", "corners"], json!(v));
@@ -539,7 +539,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) {
row(ui, "Type:");
let mut radial = d.fields.get("fx").is_some_and(|f| f["gradientFeather"]["radial"].as_bool().unwrap_or(false));
let label = |r: bool| tr(if r { "Radial" } else { "Linear" });
egui::ComboBox::from_id_salt("fx_gtype").selected_text(label(radial)).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("fx_gtype").selected_text(label(radial)).show_ui(ui, |ui| {
for r in [false, true] {
if ui.selectable_value(&mut radial, r, label(r)).clicked() {
set(d, &["gradientFeather", "radial"], json!(r));
@@ -573,7 +573,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
crate::rtl::label(ui, tr("Settings for:"));
// Stroke, Fill and Text levels aren't in the document model yet: the object only.
ui.add_enabled_ui(false, |ui| {
egui::ComboBox::from_id_salt("fx_level").selected_text(tr("Object")).width(110.0).show_ui(ui, |_| {});
craft_ui::ComboBox::from_id_salt("fx_level").selected_text(tr("Object")).width(110.0).show_ui(ui, |_| {});
});
});
ui.add_space(6.0);
+1
View File
@@ -1554,6 +1554,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "تعيين الظهور / القفل"),
("Show Panel", "إظهار اللوحة"),
("Show/Hide Baseline Grid", "إظهار/إخفاء شبكة خط الأساس"),
("Show/Hide Depth Ruler", "إظهار/إخفاء مسطرة العمق"),
("Show/Hide Document Grid", "إظهار/إخفاء شبكة المستند"),
("Show/Hide Frame Edges", "إظهار/إخفاء حواف الإطارات"),
("Show/Hide Guides", "إظهار/إخفاء الأدلة"),
+1
View File
@@ -710,6 +710,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Nastavit viditelnost / zámek"),
("Show Panel", "Zobrazit panel"),
("Show/Hide Baseline Grid", "Zobrazit/skrýt mřížku účaří"),
("Show/Hide Depth Ruler", "Zobrazit/skrýt pravítko hloubky"),
("Show/Hide Frame Edges", "Zobrazit/skrýt okraje rámečků"),
("Show/Hide Guides", "Zobrazit/skrýt vodítka"),
("Show/Hide Hidden Characters", "Zobrazit/skrýt skryté znaky"),
+1
View File
@@ -746,6 +746,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Sichtbarkeit/Sperre festlegen"),
("Show Panel", "Bedienfeld einblenden"),
("Show/Hide Baseline Grid", "Grundlinienraster ein-/ausblenden"),
("Show/Hide Depth Ruler", "Tiefenlineal ein-/ausblenden"),
("Show/Hide Frame Edges", "Rahmenkanten ein-/ausblenden"),
("Show/Hide Guides", "Hilfslinien ein-/ausblenden"),
("Show/Hide Hidden Characters", "Verborgene Zeichen ein-/ausblenden"),
+1
View File
@@ -838,6 +838,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Imposta visibilità / blocco"),
("Show Panel", "Mostra pannello"),
("Show/Hide Baseline Grid", "Mostra/nascondi griglia linee di base"),
("Show/Hide Depth Ruler", "Mostra/nascondi righello profondità"),
("Show/Hide Document Grid", "Mostra/nascondi griglia documento"),
("Show/Hide Frame Edges", "Mostra/nascondi bordi cornice"),
("Show/Hide Guides", "Mostra/nascondi guide"),
+1
View File
@@ -938,6 +938,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "表示・ロックを設定"),
("Show Panel", "パネルを表示"),
("Show/Hide Baseline Grid", "ベースライングリッドを表示・非表示"),
("Show/Hide Depth Ruler", "深さ定規を表示・非表示"),
("Show/Hide Document Grid", "ドキュメントグリッドを表示・非表示"),
("Show/Hide Frame Edges", "フレームの枠を表示・非表示"),
("Show/Hide Guides", "ガイドを表示・非表示"),
+1
View File
@@ -766,6 +766,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "ხილვადობის / ჩაკეტვის დაყენება"),
("Show Panel", "პანელის ჩვენება"),
("Show/Hide Baseline Grid", "საბაზისო ხაზების ბადის ჩვენება/დამალვა"),
("Show/Hide Depth Ruler", "სიღრმის სახაზავის ჩვენება/დამალვა"),
("Show/Hide Frame Edges", "ჩარჩოს კიდეების ჩვენება/დამალვა"),
("Show/Hide Guides", "დამხმარე ხაზების ჩვენება/დამალვა"),
("Show/Hide Hidden Characters", "დამალული სიმბოლოების ჩვენება/დამალვა"),
+1
View File
@@ -717,6 +717,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Ustaw widoczność / blokadę"),
("Show Panel", "Pokaż panel"),
("Show/Hide Baseline Grid", "Pokaż/ukryj siatkę linii bazowych"),
("Show/Hide Depth Ruler", "Pokaż/ukryj linijkę głębokości"),
("Show/Hide Frame Edges", "Pokaż/ukryj krawędzie ramek"),
("Show/Hide Guides", "Pokaż/ukryj linie pomocnicze"),
("Show/Hide Hidden Characters", "Pokaż/ukryj ukryte znaki"),
+1
View File
@@ -1685,6 +1685,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Definir visibilidade / bloqueio"),
("Show Panel", "Mostrar painel"),
("Show/Hide Baseline Grid", "Mostrar/ocultar grade de linha de base"),
("Show/Hide Depth Ruler", "Mostrar/ocultar régua de profundidade"),
("Show/Hide Frame Edges", "Mostrar/ocultar bordas dos quadros"),
("Show/Hide Guides", "Mostrar/ocultar guias"),
("Show/Hide Hidden Characters", "Mostrar/ocultar caracteres ocultos"),
+1
View File
@@ -697,6 +697,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Задать видимость / блокировку"),
("Show Panel", "Показать панель"),
("Show/Hide Baseline Grid", "Показать/скрыть сетку базовых линий"),
("Show/Hide Depth Ruler", "Показать/скрыть линейку глубины"),
("Show/Hide Frame Edges", "Показать/скрыть границы рамок"),
("Show/Hide Guides", "Показать/скрыть направляющие"),
("Show/Hide Hidden Characters", "Показать/скрыть скрытые символы"),
+10
View File
@@ -2299,6 +2299,16 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
"显示/隐藏基线网格",
],
),
(
"Show/Hide Depth Ruler",
[
"Show/Hide Depth Ruler",
"Afficher/Masquer la règle de profondeur",
"Mostrar/ocultar regla de profundidad",
"Show/Hide Depth Ruler",
"显示/隐藏深度标尺",
],
),
(
"Show/Hide Document Grid",
[
+1
View File
@@ -841,6 +841,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Установити видимість / блокування"),
("Show Panel", "Показати панель"),
("Show/Hide Baseline Grid", "Показати/приховати сітку базових ліній"),
("Show/Hide Depth Ruler", "Показати/приховати лінійку глибини"),
("Show/Hide Document Grid", "Показати/приховати сітку документа"),
("Show/Hide Frame Edges", "Показати/приховати межі фреймів"),
("Show/Hide Guides", "Показати/приховати напрямні"),
+1
View File
@@ -590,6 +590,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
"view.textThreads" => flag(&mut app.ui.text_threads),
"view.hiddenCharacters" => flag(&mut app.ui.hidden_characters),
"view.taggedFrames" => flag(&mut app.ui.tagged_frames),
"view.storyEditorDepthRuler" => flag(&mut app.ui.story_editor_depth_ruler),
"window.hidePanels" | "window.hidePanelsExceptTools" => {
let mode = if id == "window.hidePanels" { 1 } else { 2 };
app.ui.hidden_panels = if app.ui.hidden_panels == mode { 0 } else { mode };
+1
View File
@@ -103,6 +103,7 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
"view.textThreads" => app.ui.text_threads,
"view.hiddenCharacters" => app.ui.hidden_characters,
"view.taggedFrames" => app.ui.tagged_frames,
"view.storyEditorDepthRuler" => app.ui.story_editor_depth_ruler,
"changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes),
// Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies
// to (selected text, cells or text frames) has it.
+3
View File
@@ -529,6 +529,9 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:view.taggedFrames",
"ui:view.tagMarkers",
"<",
">Story Editor",
"ui:view.storyEditorDepthRuler",
"<",
],
),
(
+1
View File
@@ -17,6 +17,7 @@ fn view_items_are_in_the_view_menu_as_in_indesign() {
"view.separations",
"view.taggedFrames",
"view.tagMarkers",
"view.storyEditorDepthRuler",
] {
assert!(view.contains(&id), "{id} is in View");
assert!(!typ.contains(&id), "{id} isn't in Type");
+6
View File
@@ -232,6 +232,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"),
("view.hiddenCharacters", "Show Hidden Characters", Some("Cmd+Alt+I"), "{}"),
("view.taggedFrames", "Show/Hide Tagged Frames", None, "{} — XML-tagged frames outlined in their tag colour"),
(
"view.storyEditorDepthRuler",
"Show/Hide Depth Ruler",
None,
"{} — the Story Editor's vertical ruler of how deep the text reaches in its frames",
),
("window.hidePanels", "Show/Hide Panels", Some("Tab"), "{} — every panel, the Tools panel included"),
("window.hidePanelsExceptTools", "Show/Hide Panels Except Tools", Some("Shift+Tab"), "{}"),
("window.nextDocument", "Next Document", Some("Cmd+F6"), "{}"),
+10 -7
View File
@@ -771,14 +771,17 @@ fn details(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) {
row(ui, rtl, |ui| {
cell(ui, rtl, tr("Width"), |ui| spin(ui, d, "width", COLUMN_W - 8.0, Step::Measure(1.0)));
cell(ui, rtl, tr("Units"), |ui| {
egui::ComboBox::from_id_salt("nd_units").selected_text(crate::rtl::widget(ui, tr(u.label()))).width(COLUMN_W - 8.0).show_ui(ui, |ui| {
// InDesign's eight; a preset in another unit keeps it on the list.
for v in NEW_DOCUMENT_UNITS.into_iter().chain((!NEW_DOCUMENT_UNITS.contains(&u)).then_some(u)) {
if ui.selectable_label(v == u, crate::rtl::widget(ui, tr(v.label()))).clicked() && v != u {
set_units(d, v);
craft_ui::ComboBox::from_id_salt("nd_units").selected_text(crate::rtl::widget(ui, tr(u.label()))).width(COLUMN_W - 8.0).show_ui(
ui,
|ui| {
// InDesign's eight; a preset in another unit keeps it on the list.
for v in NEW_DOCUMENT_UNITS.into_iter().chain((!NEW_DOCUMENT_UNITS.contains(&u)).then_some(u)) {
if ui.selectable_label(v == u, crate::rtl::widget(ui, tr(v.label()))).clicked() && v != u {
set_units(d, v);
}
}
}
});
},
);
});
});
row(ui, rtl, |ui| {
+21 -13
View File
@@ -3,7 +3,7 @@
pub mod cell_stroke;
pub mod conditions;
mod font_menu;
pub use font_menu::{FontPick, SAMPLES as FONT_SAMPLES, font_menu_body};
pub use font_menu::{FONT_MENU_HEIGHT, FontMenu, FontPick, SAMPLES as FONT_SAMPLES, font_menu_header, font_menu_list};
pub mod datamerge;
pub mod glyphs;
pub mod interactive;
@@ -383,7 +383,8 @@ pub fn swatch_picker(
Some(resp)
}
/// A font family combo box showing `current` by its menu name, with [`font_menu_body`] as its menu.
/// A font family combo box showing `current` by its menu name, with the Font menu
/// ([`font_menu_header`]) as its menu.
/// Returns the font picked.
pub fn font_combo(
app: &mut DesignApp,
@@ -394,15 +395,20 @@ pub fn font_combo(
) -> Option<FontPick> {
let menu = font_menu(app);
let shown = if current.is_empty() { "—".to_string() } else { font_label(app, &menu, current) };
// A field and a plain popup rather than egui's combo box: its own scroll area would keep the
// menu at the size it last had (a family opening, a filter clearing, wouldn't show).
let field = ui.push_id(id_salt, |ui| crate::widgets::dropdown(ui, &shown, width)).inner;
font_popup(app, &field, &menu, current)
font_popup(app, ui, &field, &menu, current)
}
/// [`font_menu_body`] in a popup under `field` (a font field the caller draws), which screen
/// readers hear as a combo box with the current family as its value. Returns the font picked.
pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<FontPick> {
/// The Font menu ([`font_menu_header`]) in a menu under `field` (a font field the caller draws in
/// `ui`), which screen readers hear as a combo box with the current family as its value. Returns
/// the font picked.
pub fn font_popup(
app: &mut DesignApp,
ui: &egui::Ui,
field: &egui::Response,
menu: &[designcraft_fonts::FamilyInfo],
current: &str,
) -> Option<FontPick> {
let shown = if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) };
field.widget_info(|| {
let mut info = egui::WidgetInfo::new(egui::Role::ComboBox);
@@ -411,10 +417,12 @@ pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcra
info.label = Some(crate::i18n::tr(&app.ui.language, "Font Family").to_owned());
info
});
egui::Popup::menu(field)
let width = field.rect.width();
craft_ui::combo::Menu::new(ui, field)
.height(FONT_MENU_HEIGHT)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| font_menu_body(app, ui, menu, current, field.rect.width()))
.and_then(|r| r.inner)
.show_ui_with_header(|ui| font_menu_header(app, ui, menu, current, width), font_menu_list)
.flatten()
}
/// The style a pick sets: the one picked, else the family's Regular (else its first).
@@ -454,7 +462,7 @@ pub fn style_shown(current: &str) -> String {
pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) {
let styles = fonts(app).styles(family);
let mut pick = None;
let combo = egui::ComboBox::from_id_salt("font_style").selected_text(style_shown(current)).width(width).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt("font_style").selected_text(style_shown(current)).width(width).show_ui(ui, |ui| {
for s in &styles {
if ui.selectable_label(s == current, s).clicked() {
pick = Some(s.clone());
@@ -472,7 +480,7 @@ pub fn para_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
let mut pick = None;
let shown = crate::i18n::style_name(&app.ui.language, current);
let label = if overridden { format!("{shown}+") } else { shown.to_string() };
let combo = egui::ComboBox::from_id_salt("para_style").selected_text(crate::rtl::widget(ui, label)).width(width).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt("para_style").selected_text(crate::rtl::widget(ui, label)).width(width).show_ui(ui, |ui| {
for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) {
if ui.selectable_label(n == current, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
pick = Some(n.clone());
+1 -1
View File
@@ -192,7 +192,7 @@ pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> O
ui.end_row();
caption(ui, tr(&lang, "Type:"));
let shown = type_label(&lang, &v["type"]);
let combo = egui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| {
for kind in TYPES {
let text = tr(&lang, builtin(kind).label());
if ui.selectable_label(shown == text, crate::rtl::widget(ui, text)).clicked() {
+1 -1
View File
@@ -197,7 +197,7 @@ fn select_source(app: &mut DesignApp) {
fn choice(ui: &mut egui::Ui, id: &str, current: &str, options: &[(&str, &str)], lang: &str, name: &str) -> Option<String> {
let label = options.iter().find(|(value, _)| *value == current).map(|(_, text)| tr(lang, text)).unwrap_or(current);
let mut picked = None;
let combo = egui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| {
for (value, text) in options {
if ui.selectable_label(*value == current, tr(lang, text)).clicked() {
picked = Some((*value).to_string());
+141 -113
View File
@@ -297,13 +297,41 @@ fn clock(p: &egui::Painter, r: Rect, color: Color32) {
/// one. A click on a family picks it (its Regular style), on one of its styles that style; the
/// arrow opens a family, the star marks a favourite and ≈ shows similar fonts, all keeping the menu
/// open (it closes on a click outside only).
pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], current: &str, width: f32) -> Option<FontPick> {
///
/// [`font_menu_header`] lays out the search field and the filters, [`font_menu_list`] the rows
/// under them: the header and list of a [`craft_ui::combo::Menu`] or [`craft_ui::ComboBox`], so
/// only the rows scroll. Rows a field adds above the search (a blank "no font" row) go in the
/// header before [`font_menu_header`].
pub struct FontMenu<'a> {
app: &'a mut DesignApp,
menu: &'a [FamilyInfo],
current: &'a str,
st: MenuState,
rows: Vec<Row>,
highlighted_row: Option<usize>,
loading: bool,
sample: String,
row_h: f32,
sample_size: f32,
down: bool,
up: bool,
escape: bool,
pick: Option<FontPick>,
}
/// The Font menu's maximum height, its search field and filters included.
pub const FONT_MENU_HEIGHT: f32 = 500.0;
const STATE_ID: &str = "font_menu_state";
/// The Font menu's search field and filters, for the header of a menu whose list is
/// [`font_menu_list`]. See [`FontMenu`].
pub fn font_menu_header<'a>(app: &'a mut DesignApp, ui: &mut Ui, menu: &'a [FamilyInfo], current: &'a str, width: f32) -> FontMenu<'a> {
let lang = app.ui.language.clone();
let tr = |s: &'static str| crate::i18n::tr(&lang, s);
let (fonts, scope) = (super::fonts(app), super::font_scope(app));
let english = app.session.prefs.show_font_names_in_english;
let mut favs = app.session.prefs.favorite_fonts.clone();
let state_id = egui::Id::unique("font_menu_state");
let favs = app.session.prefs.favorite_fonts.clone();
let state_id = egui::Id::unique(STATE_ID);
let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
let frame = ui.ctx().cumulative_frame_nr();
if st.frame.is_none_or(|f| f.saturating_add(1) < frame) {
@@ -315,7 +343,6 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur
let t = Tokens::get(ui.ctx());
ui.set_width(width.max(560.0));
let mut pick: Option<FontPick> = None;
let mut favs_changed = false;
// The keys the menu uses are its own: a dialog under it never sees Return or Escape.
let (down, up, enter, escape) = ui.input_mut(|i| {
let mut key = |k| i.consume_key(egui::Modifiers::NONE, k);
@@ -506,125 +533,126 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur
};
}
FontMenu { app, menu, current, st, rows, highlighted_row, loading, sample, row_h, sample_size, down, up, escape, pick }
}
/// The Font menu's rows, under [`font_menu_header`]. Returns the font picked.
pub fn font_menu_list(ui: &mut Ui, m: FontMenu<'_>) -> Option<FontPick> {
let FontMenu { app, menu, current, mut st, rows, highlighted_row, loading, sample, row_h, sample_size, down, up, escape, mut pick } = m;
let lang = app.ui.language.clone();
let tr = |s: &'static str| crate::i18n::tr(&lang, s);
let (fonts, scope) = (super::fonts(app), super::font_scope(app));
let english = app.session.prefs.show_font_names_in_english;
let mut favs = app.session.prefs.favorite_fonts.clone();
let state_id = egui::Id::unique(STATE_ID);
let t = Tokens::get(ui.ctx());
let mut favs_changed = false;
let mut toggle_expand = None;
let mut toggle_fav = None;
let mut similar = None;
// A popup only offers last frame's height: ask for the rows' own (a family just opened, a
// filter just cleared), up to the list's limit.
let list_h: f32 = rows
.iter()
.map(|r| match r {
Row::Family { .. } => row_h + ui.spacing().item_spacing.y,
Row::Style { .. } => row_h.min(30.0) + ui.spacing().item_spacing.y,
Row::Separator => 8.0,
})
.sum();
egui::ScrollArea::vertical().max_height(420.0).min_scrolled_height(list_h.clamp(24.0, 420.0)).auto_shrink([false, true]).show(ui, |ui| {
if loading {
crate::rtl::label(ui, egui::RichText::new(tr("Loading fonts…")).color(t.text_dim));
} else if rows.is_empty() {
crate::rtl::label(ui, egui::RichText::new(tr("No fonts match")).color(t.text_dim));
}
for (i, row) in rows.iter().enumerate() {
let (family, style, recent) = match row {
Row::Separator => {
ui.separator();
continue;
}
Row::Family { info, recent } => (info, None, *recent),
Row::Style { family, style } => {
let Some(info) = menu.iter().find(|f| f.family == *family) else { continue };
(info, Some(style.as_str()), false)
}
};
let f = &family.family;
let (r, mut resp) =
ui.allocate_exact_size(vec2(ui.available_width(), if style.is_some() { row_h.min(30.0) } else { row_h }), Sense::click());
let highlighted = highlighted_row == Some(i);
if highlighted && (down || up) {
ui.scroll_to_rect(r, None);
}
if !ui.is_rect_visible(r) {
if loading {
crate::rtl::label(ui, egui::RichText::new(tr("Loading fonts…")).color(t.text_dim));
} else if rows.is_empty() {
crate::rtl::label(ui, egui::RichText::new(tr("No fonts match")).color(t.text_dim));
}
for (i, row) in rows.iter().enumerate() {
let (family, style, recent) = match row {
Row::Separator => {
ui.separator();
continue;
}
let label = match style {
Some(s) => s.to_string(),
None => family.label(english).to_string(),
};
let is_current = style.is_none() && f == current;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_current, &label));
if style.is_none() && label != *f {
resp = resp.on_hover_text(f);
Row::Family { info, recent } => (info, None, *recent),
Row::Style { family, style } => {
let Some(info) = menu.iter().find(|f| f.family == *family) else { continue };
(info, Some(style.as_str()), false)
}
// Over the row or one of its buttons (the star, the arrow, ≈ are on top of it).
let hovered = resp.contains_pointer();
if is_current {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if highlighted || hovered {
ui.painter().rect_filled(r, 0.0, t.hover);
};
let f = &family.family;
let (r, mut resp) = ui.allocate_exact_size(vec2(ui.available_width(), if style.is_some() { row_h.min(30.0) } else { row_h }), Sense::click());
let highlighted = highlighted_row == Some(i);
if highlighted && (down || up) {
ui.scroll_to_rect(r, None);
}
if !ui.is_rect_visible(r) {
continue;
}
let label = match style {
Some(s) => s.to_string(),
None => family.label(english).to_string(),
};
let is_current = style.is_none() && f == current;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_current, &label));
if style.is_none() && label != *f {
resp = resp.on_hover_text(f);
}
// Over the row or one of its buttons (the star, the arrow, ≈ are on top of it).
let hovered = resp.contains_pointer();
if is_current {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if highlighted || hovered {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let p = ui.painter_at(r);
let m = meta(ui, &fonts, scope, f);
let cy = r.center().y;
let mut handled = false;
if style.is_none() {
// Favourite star.
let star = Rect::from_center_size(pos2(r.min.x + 10.0, cy), vec2(16.0, 16.0));
let fav = favs.contains(f);
let sr = ui.interact(star, ui.scope_id().with(("fav", f, recent)), Sense::click());
paint_star(&p, star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim });
if sr.clicked() {
toggle_fav = Some(f.clone());
handled = true;
}
let p = ui.painter_at(r);
let m = meta(ui, &fonts, scope, f);
let cy = r.center().y;
let mut handled = false;
if style.is_none() {
// Favourite star.
let star = Rect::from_center_size(pos2(r.min.x + 10.0, cy), vec2(16.0, 16.0));
let fav = favs.contains(f);
let sr = ui.interact(star, ui.scope_id().with(("fav", f, recent)), Sense::click());
paint_star(&p, star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim });
if sr.clicked() {
toggle_fav = Some(f.clone());
// The arrow opens a family of several styles.
if m.styles.len() > 1 && !recent {
let arrow = Rect::from_center_size(pos2(r.min.x + 28.0, cy), vec2(16.0, 16.0));
let ar = ui.interact(arrow, ui.scope_id().with(("open", f)), Sense::click());
ar.widget_info(|| {
egui::WidgetInfo::labeled(egui::Role::Button, true, tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" }))
});
let open = st.expanded.contains(f);
crate::icons::paint(&p, arrow.shrink(2.0), if open { "chevron-down" } else { "chevron-right" }, t.icon);
if ar.clicked() {
toggle_expand = Some(f.clone());
handled = true;
}
// The arrow opens a family of several styles.
if m.styles.len() > 1 && !recent {
let arrow = Rect::from_center_size(pos2(r.min.x + 28.0, cy), vec2(16.0, 16.0));
let ar = ui.interact(arrow, ui.scope_id().with(("open", f)), Sense::click());
ar.widget_info(|| {
egui::WidgetInfo::labeled(egui::Role::Button, true, tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" }))
});
let open = st.expanded.contains(f);
crate::icons::paint(&p, arrow.shrink(2.0), if open { "chevron-down" } else { "chevron-right" }, t.icon);
if ar.clicked() {
toggle_expand = Some(f.clone());
handled = true;
}
}
}
// Name (and how many styles), then the sample in the face, then the format.
let name_x = r.min.x + if style.is_some() { 48.0 } else { 40.0 };
let name = if style.is_none() && m.styles.len() > 1 { format!("{label} ({})", m.styles.len()) } else { label.clone() };
p.text(pos2(name_x, cy), Align2::LEFT_CENTER, &name, FontId::proportional(12.5), t.text);
let preview_x = r.min.x + (r.width() * 0.42).max(190.0);
let icons_x = r.max.x - 52.0;
let face_style = style.map_or_else(|| regular(&m.styles), str::to_string);
let tex = preview(ui, &fonts, scope, f, &face_style, &sample, sample_size, t.text);
let size = tex.size_vec2() / ui.ctx().pixels_per_point();
let shown = Rect::from_min_size(pos2(preview_x, cy - size.y / 2.0), size);
let clip = Rect::from_min_max(pos2(preview_x, r.min.y), pos2(icons_x - 6.0, r.max.y));
ui.painter_at(clip).image(tex.id(), shown, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
// Show Similar Fonts (on hover), and the format.
if style.is_none() && (hovered || st.similar.as_deref() == Some(f.as_str())) {
let sim = Rect::from_center_size(pos2(icons_x + 12.0, cy), vec2(18.0, 18.0));
let sr = ui.interact(sim, ui.scope_id().with(("similar", f, recent)), Sense::click());
sr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tr("Show Similar Fonts")));
p.text(sim.center(), Align2::CENTER_CENTER, "≈", FontId::proportional(15.0), if sr.hovered() { t.text_strong } else { t.icon });
if sr.on_hover_text(tr("Show Similar Fonts")).clicked() {
similar = Some(f.clone());
handled = true;
}
}
if style.is_none() {
let badge = Rect::from_center_size(pos2(r.max.x - 16.0, cy), vec2(20.0, 16.0));
paint_format(&p, badge, m.kind.format, t.text_dim, t.accent);
ui.interact(badge, ui.scope_id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format)));
}
if resp.clicked() && !handled {
pick = Some(FontPick { family: f.clone(), style: style.map(str::to_string) });
}
}
});
// Name (and how many styles), then the sample in the face, then the format.
let name_x = r.min.x + if style.is_some() { 48.0 } else { 40.0 };
let name = if style.is_none() && m.styles.len() > 1 { format!("{label} ({})", m.styles.len()) } else { label.clone() };
p.text(pos2(name_x, cy), Align2::LEFT_CENTER, &name, FontId::proportional(12.5), t.text);
let preview_x = r.min.x + (r.width() * 0.42).max(190.0);
let icons_x = r.max.x - 52.0;
let face_style = style.map_or_else(|| regular(&m.styles), str::to_string);
let tex = preview(ui, &fonts, scope, f, &face_style, &sample, sample_size, t.text);
let size = tex.size_vec2() / ui.ctx().pixels_per_point();
let shown = Rect::from_min_size(pos2(preview_x, cy - size.y / 2.0), size);
let clip = Rect::from_min_max(pos2(preview_x, r.min.y), pos2(icons_x - 6.0, r.max.y));
ui.painter_at(clip).image(tex.id(), shown, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
// Show Similar Fonts (on hover), and the format.
if style.is_none() && (hovered || st.similar.as_deref() == Some(f.as_str())) {
let sim = Rect::from_center_size(pos2(icons_x + 12.0, cy), vec2(18.0, 18.0));
let sr = ui.interact(sim, ui.scope_id().with(("similar", f, recent)), Sense::click());
sr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tr("Show Similar Fonts")));
p.text(sim.center(), Align2::CENTER_CENTER, "≈", FontId::proportional(15.0), if sr.hovered() { t.text_strong } else { t.icon });
if sr.on_hover_text(tr("Show Similar Fonts")).clicked() {
similar = Some(f.clone());
handled = true;
}
}
if style.is_none() {
let badge = Rect::from_center_size(pos2(r.max.x - 16.0, cy), vec2(20.0, 16.0));
paint_format(&p, badge, m.kind.format, t.text_dim, t.accent);
ui.interact(badge, ui.scope_id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format)));
}
if resp.clicked() && !handled {
pick = Some(FontPick { family: f.clone(), style: style.map(str::to_string) });
}
}
if let Some(f) = toggle_expand
&& !st.expanded.remove(&f)
{
@@ -301,3 +301,66 @@ fn font_fields_in_panels_and_dialogs_search() {
assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open");
}
}
/// The font rows the open menu shows under its search field `search`: how many, and the bottom
/// of the lowest.
fn rows_shown(h: &Harness<'static, Window>, search: egui::Rect) -> (usize, f32) {
let families: Vec<String> = crate::panels::font_menu(&h.state().app).iter().map(|f| f.label(true).to_string()).collect();
let rows: Vec<egui::Rect> = h
.query_all_by(|n| n.role() == egui::accesskit::Role::Button && n.label().is_some_and(|l| families.contains(&l)))
.map(|n| n.rect())
.filter(|r| r.min.y > search.max.y)
.collect();
(rows.len(), rows.iter().map(|r| r.max.y).fold(f32::MIN, f32::max))
}
/// Opens the font menu of the field at `at`, searches for a font that doesn't exist and clears
/// the search: the menu grows back to the rows it showed, with the search field where it was.
/// The menu lists every family by its English name: the test UI has no CJK font for a system
/// CJK family's native name.
fn menu_regrows_after_a_search_with_no_matches(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) {
h.state_mut().app.session.prefs.show_font_names_in_english = true;
click_at(h, at.center());
h.run_steps(4);
let focused = |h: &Harness<'static, Window>| h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect());
let field = focused(h).unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused"));
let (count, bottom) = rows_shown(h, field);
assert!(count > 1, "{what}: the menu lists the fonts: {count}");
let query = "zzqqxxnomatch";
h.event(egui::Event::Text(query.into()));
h.run_steps(4);
assert!(h.query_by_label("No fonts match").is_some(), "{what}: nothing matches");
assert_eq!(rows_shown(h, field).0, 0, "{what}: no rows while nothing matches");
for _ in query.chars() {
h.key_press(egui::Key::Backspace);
}
h.run_steps(4);
assert_eq!(search(h), "", "{what}: the search is cleared");
let again = focused(h).unwrap_or_else(|| panic!("{what}: the menu is still open"));
assert!((again.min.y - field.min.y).abs() < 0.5, "{what}: the search field stays at {}, not {}", field.min.y, again.min.y);
let (count_again, bottom_again) = rows_shown(h, again);
assert_eq!(count_again, count, "{what}: the menu shows as many rows as before the search");
assert!((bottom_again - bottom).abs() < 1.0, "{what}: the menu grows back to {bottom}, not {bottom_again}");
}
#[test]
fn the_font_field_menu_grows_back_after_a_search_with_no_matches() {
let (mut h, _) = typing();
let at = field(&h, designcraft_fonts::DEFAULT_FAMILY);
menu_regrows_after_a_search_with_no_matches(&mut h, at, "Control bar");
}
/// Paragraph Style Options › Basic Character Formats' Font Family combo box. The CJK style
/// fields' combo box hosts the same menu the same way.
#[test]
fn the_style_dialog_font_menu_grows_back_after_a_search_with_no_matches() {
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mut h = test_window::open(app, vec2(1440.0, 900.0));
let section = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"});
h.state_mut().app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", section));
h.run_steps(4);
let at = *fields(&h, designcraft_fonts::DEFAULT_FAMILY).first().expect("the dialog's font field is shown");
menu_regrows_after_a_search_with_no_matches(&mut h, at, "Paragraph Style Options");
}
+1 -1
View File
@@ -38,7 +38,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
st.style = f.style.clone().or_else(|| db.styles(&f.family).into_iter().next()).unwrap_or_else(|| "Regular".into());
st.family = f.family;
}
let combo = egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| {
for s in db.styles(&st.family) {
if ui.selectable_label(s == st.style, &s).clicked() {
st.style = s;
+4 -4
View File
@@ -365,7 +365,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) {
let mut pick = None;
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "On Release"));
let combo = egui::ComboBox::from_id_salt("button_action")
let combo = craft_ui::ComboBox::from_id_salt("button_action")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, labels.iter().find(|l| l.0 == kind).map_or("", |l| l.1))))
.show_ui(ui, |ui| {
for (k, l) in labels {
@@ -426,7 +426,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) {
let cur = ff.as_ref().map(|f| f.kind);
ui.horizontal(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Form Field"));
let combo = egui::ComboBox::from_id_salt("form_kind")
let combo = craft_ui::ComboBox::from_id_salt("form_kind")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, kinds.iter().find(|k| k.0 == cur).map_or("", |k| k.1))))
.show_ui(ui, |ui| {
for (k, l) in kinds {
@@ -539,7 +539,7 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) {
crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page)));
});
});
let combo = egui::ComboBox::from_id_salt("liquid_rule")
let combo = craft_ui::ComboBox::from_id_salt("liquid_rule")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid))))
.width(190.0)
.show_ui(ui, |ui| {
@@ -669,7 +669,7 @@ pub fn transitions(app: &mut DesignApp, ui: &mut egui::Ui) {
use designcraft_doc::TransitionKind as K;
crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Spread"), si + 1)).strong());
let mut kind = cur.map(|c| c.kind);
let combo = egui::ComboBox::from_id_salt("transition_kind")
let combo = craft_ui::ComboBox::from_id_salt("transition_kind")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, kind.map_or("None", K::label))))
.show_ui(ui, |ui| {
ui.selectable_value(&mut kind, None, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "None")));
@@ -66,7 +66,7 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
StrokeType::Style { name } => name.clone(),
k => k.label().to_string(),
};
let combo = egui::ComboBox::from_id_salt("stype")
let combo = craft_ui::ComboBox::from_id_salt("stype")
.selected_text(crate::rtl::widget(
ui,
if matches!(st.kind, StrokeType::Style { .. }) { &shown } else { crate::i18n::tr(&app.ui.language, &shown) },
@@ -106,7 +106,7 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.end_row();
for (key, label, cur, flip) in [("start", "Start/End:", st.start, true), ("end", "", st.end, false)] {
caption(ui, crate::i18n::tr(&app.ui.language, label));
let combo = egui::ComboBox::from_id_salt(key)
let combo = craft_ui::ComboBox::from_id_salt(key)
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cur.label())))
.width(140.0)
.show_ui(ui, |ui| {
@@ -305,7 +305,7 @@ fn wrap_combo(
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, caption_text));
let shown = opts.iter().chain(shown_only).find(|o| o.0 == cur).map_or(cur, |o| o.1).to_string();
let combo = egui::ComboBox::from_id_salt(salt)
let combo = craft_ui::ComboBox::from_id_salt(salt)
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &shown)))
.width(170.0)
.show_ui(ui, |ui| {
@@ -468,7 +468,7 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
B::Luminosity,
];
ui.horizontal(|ui| {
let combo = egui::ComboBox::from_id_salt("blend")
let combo = craft_ui::ComboBox::from_id_salt("blend")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, it.blend.label())))
.width(130.0)
.show_ui(ui, |ui| {
+1 -1
View File
@@ -179,7 +179,7 @@ pub(super) fn character_section(app: &mut DesignApp, ui: &mut Ui) {
egui::FontId::proportional(11.5),
t.text,
);
if let Some(f) = crate::panels::font_popup(app, &resp, &menu, &fam) {
if let Some(f) = crate::panels::font_popup(app, ui, &resp, &menu, &fam) {
crate::panels::apply_font(app, &f);
}
ui.add_space(1.0);
@@ -16,7 +16,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let lang = c["language"].as_str().unwrap_or("English: USA").to_string();
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Language"));
let combo = egui::ComboBox::from_id_salt("char_language")
let combo = craft_ui::ComboBox::from_id_salt("char_language")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &lang)))
.width(170.0)
.show_ui(ui, |ui| language_menu(app, ui, &lang));
@@ -30,7 +30,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
.iter()
.find(|o| o.0 == digits)
.map_or_else(|| digit_label(&app.ui.language, DIGIT_OPTIONS[0]), |o| digit_label(&app.ui.language, *o));
let combo = egui::ComboBox::from_id_salt("char_digits").selected_text(crate::rtl::widget(ui, cur)).width(150.0).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt("char_digits").selected_text(crate::rtl::widget(ui, cur)).width(150.0).show_ui(ui, |ui| {
for o in DIGIT_OPTIONS {
if ui.selectable_label(o.0 == digits, crate::rtl::widget(ui, digit_label(&app.ui.language, o))).clicked() {
let _ = app.run("type.char", json!({"attrs": {"digits": o.0}}));
+2 -2
View File
@@ -401,7 +401,7 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}
ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let combo = egui::ComboBox::from_id_salt("color_mode")
let combo = craft_ui::ComboBox::from_id_salt("color_mode")
.width(64.0)
.selected_text(match mode {
ColorMode::Cmyk => "CMYK",
@@ -741,7 +741,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Type:"), 52.0);
let shown = if is_gradient { crate::i18n::tr(&lang, g.kind.label()) } else { "" };
let combo =
egui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| {
for (k, v) in [("Linear", "linear"), ("Radial", "radial")] {
if ui
.selectable_label(is_gradient && g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&lang, k)))
+2 -2
View File
@@ -69,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
for (label, kind, names, cur) in [("Table Style", "table", tnames, tcur), ("Cell Style", "cell", cnames, ccur)] {
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, label));
let combo = egui::ComboBox::from_id_salt(("tp_style", kind)).selected_text(&cur).width(130.0).show_ui(ui, |ui| {
let combo = craft_ui::ComboBox::from_id_salt(("tp_style", kind)).selected_text(&cur).width(130.0).show_ui(ui, |ui| {
for n in &names {
if ui.selectable_label(*n == cur, n).clicked() {
run(app, &format!("style.{kind}.apply"), json!({"name": n, "table": tid}));
@@ -126,7 +126,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let row = rows.get(r0).cloned().unwrap_or(Value::Null);
let exactly = row["mode"] == "exactly";
caption(ui, crate::i18n::tr(&app.ui.language, "Row Height"));
let combo = egui::ComboBox::from_id_salt("tp_rowmode")
let combo = craft_ui::ComboBox::from_id_salt("tp_rowmode")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if exactly { "Exactly" } else { "At Least" })))
.width(70.0)
.show_ui(ui, |ui| {
+1 -1
View File
@@ -105,7 +105,7 @@ fn text(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) {
fn style_menu(ui: &mut Ui, d: &mut Dialog, key: &str) {
let current: NumberStyle = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let shown = STYLES.iter().find(|(s, _)| *s == current).map_or(STYLES[0].1, |(_, l)| l);
egui::ComboBox::from_id_salt(("section_style", key)).selected_text(shown).width(150.0).show_ui(ui, |ui| {
craft_ui::ComboBox::from_id_salt(("section_style", key)).selected_text(shown).width(150.0).show_ui(ui, |ui| {
for (style, label) in STYLES {
if ui.selectable_label(style == current, label).clicked() {
d.fields.insert(key.into(), json!(style));
+3
View File
@@ -222,6 +222,8 @@ pub struct UiState {
pub dynamic_spelling: bool,
/// Preferences › Story Editor Display: text size (points).
pub story_editor_size: f32,
/// View › Story Editor › Show/Hide Depth Ruler.
pub story_editor_depth_ruler: bool,
/// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`.
pub language: String,
/// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export.
@@ -394,6 +396,7 @@ impl Default for UiState {
hidden_menu_items: Vec::new(),
show_full_menus: false,
story_editor_size: 14.0,
story_editor_depth_ruler: true,
scripts: vec![
("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()),
("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()),
+398 -5
View File
@@ -1,15 +1,23 @@
//! Story Editor (Edit → Edit in Story Editor, ⌘Y): the story as plain text with each paragraph's
//! style in the left column and the overset point marked. Edits are applied as minimal range
//! replacements, so formatting around them is kept.
//! style in the left column, the depth ruler beside it and the overset point marked. Edits are
//! applied as minimal range replacements, so formatting around them is kept.
use egui::vec2;
use egui::{pos2, vec2};
use serde_json::json;
use designcraft_compose::ComposedStory;
use designcraft_doc::StoryId;
use crate::DesignApp;
use crate::theme::Tokens;
/// Width of the depth ruler, in screen points.
const RULER_WIDTH: f32 = 34.0;
/// Least distance between two of the depth ruler's labels, in screen points.
const RULER_LABEL_GAP: f32 = 10.0;
/// Color of the overset line and label.
const OVERSET_RED: egui::Color32 = egui::Color32::from_rgb(230, 40, 40);
/// The story open in the Story Editor. Story ids are counted per document, so the editor is bound
/// to the document the story belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -41,6 +49,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let cs = app.session.cache.get(&st.doc, sid, None);
let overset = cs.overset_at;
let depths = app.ui.story_editor_depth_ruler.then(|| line_depths(&cs));
let unit = st.doc.settings.vertical_units;
let mut open = true;
let mut edit: Option<(usize, usize, String)> = None;
let words = story.text.split_whitespace().count().to_string();
@@ -54,14 +64,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
// Style column: the labels are painted after the text, at the rows the text really wraps to.
let (column, _) = ui.allocate_exact_size(vec2(130.0, 0.0), egui::Sense::hover());
ui.separator();
// Depth ruler: painted after the text too, beside the rows the lines start on.
let ruler = depths.as_ref().map(|_| ui.allocate_exact_size(vec2(RULER_WIDTH, 0.0), egui::Sense::hover()).0);
let mut text = story.text.clone();
// (a fixed id: the ruler's allocation above would otherwise move the editor's auto id,
// and its focus and caret with it, whenever the ruler is shown or hidden)
let out = egui::TextEdit::multiline(&mut text)
.id_salt("story_text")
.font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0)))
.desired_width(f32::INFINITY)
.desired_rows(20)
.frame(egui::Frame::NONE)
.show(ui);
let r = out.response;
let changed = r.changed();
let starts = paragraph_start_offsets(out.galley.rows.iter().map(|row| (row.pos.y, row.ends_with_newline)));
for (i, y) in starts.into_iter().enumerate() {
let Some(para) = story.paras.get(i) else { break };
@@ -71,7 +87,34 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim));
});
}
if r.changed() {
let overset_y = overset.filter(|_| !changed).map(|o| {
let ci = char_offsets(&story.text, std::iter::once(o));
out.galley_pos.y + start_rows(&out.galley, &ci).first().map_or(0.0, |row| row.0)
});
if let (Some(ruler), Some(depths)) = (ruler, depths.as_ref().filter(|_| !changed)) {
let bottom = overset_y.unwrap_or(out.galley_pos.y + out.galley.size().y);
let bar = egui::Rect::from_min_max(pos2(ruler.min.x, out.galley_pos.y), pos2(ruler.min.x + RULER_WIDTH, bottom));
ui.painter().rect_filled(bar, 0.0, t.ruler);
let ticks = depth_ticks(depths, unit.ruler_ticks(1.0).0);
let rows = start_rows(&out.galley, &char_offsets(&story.text, ticks.iter().map(|tick| tick.0)));
let marks = ticks.iter().zip(rows).map(|(&(_, depth), row)| (out.galley_pos.y + row.1, depth));
for (y, depth) in spaced_marks(marks, RULER_LABEL_GAP) {
ui.painter().line_segment([pos2(bar.max.x - 8.0, y), pos2(bar.max.x, y)], egui::Stroke::new(1.0, t.ruler_tick));
ui.painter().text(
pos2(bar.min.x + 2.0, y - 1.0),
egui::Align2::LEFT_BOTTOM,
crate::canvas::fmt_tick(unit.from_pt(depth)),
egui::FontId::proportional(9.0),
t.ruler_text,
);
}
}
// The overset line: across the text, and the ruler when it shows, above the first character that doesn't fit.
if let Some(y) = overset_y {
let x0 = ruler.map_or(r.rect.min.x, |rr| rr.min.x);
ui.painter().line_segment([pos2(x0, y), pos2(r.rect.max.x, y)], egui::Stroke::new(1.5, OVERSET_RED));
}
if changed {
// Minimal diff: common prefix and suffix.
let old = &story.text;
let pre = old.bytes().zip(text.bytes()).take_while(|(a, b)| a == b).count();
@@ -88,7 +131,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
ui.label(crate::rtl::widget(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "OVERSET")).color(egui::Color32::from_rgb(230, 40, 40)).strong(),
egui::RichText::new(crate::i18n::tr(&app.ui.language, "OVERSET")).color(OVERSET_RED).strong(),
));
crate::rtl::label(
ui,
@@ -125,6 +168,128 @@ fn paragraph_start_offsets(rows: impl Iterator<Item = (f32, bool)>) -> Vec<f32>
starts
}
/// The depth ruler's values: for each composed line that fits, its first byte and how deep the
/// story's text fills its frames at that line (points). Each line covers the band of its column
/// from one leading above its bottom (its ascent and descent when larger) to its bottom; the depth
/// is the height of the bands covered so far, added up over the story's columns and threaded
/// frames. A line set below the line before it in the same column also covers the space between
/// them (paragraph spacing, or the room a wrap object takes). Lines set side by side in one column
/// (the sub-columns of a split paragraph, or text on both sides of a wrap object) cover one band,
/// so they count once.
fn line_depths(cs: &ComposedStory) -> Vec<(usize, f64)> {
let mut out = Vec::new();
let mut depth = 0.0;
for ft in &cs.frames {
let mut covered: std::collections::BTreeMap<u32, Vec<(f64, f64)>> = std::collections::BTreeMap::new();
let mut before: Option<(u32, f64)> = None;
for line in &ft.lines {
let bottom = line.baseline + line.descent;
let mut top = bottom - line.leading.max(line.ascent + line.descent);
if let Some((column, above)) = before
&& column == line.column
&& above < top
{
top = above;
}
before = Some((line.column, bottom));
if top.is_finite() && bottom > top {
depth += cover(covered.entry(line.column).or_default(), top, bottom);
}
out.push((line.range.start, depth));
}
}
out
}
/// Adds the band `top..bottom` to `bands` (sorted, apart from one another) and returns the height
/// it covers that `bands` did not.
fn cover(bands: &mut Vec<(f64, f64)>, top: f64, bottom: f64) -> f64 {
let overlap: f64 = bands.iter().map(|&(a, b)| (b.min(bottom) - a.max(top)).max(0.0)).sum();
let (mut a, mut b) = (top, bottom);
bands.retain(|&(s, e)| {
let touches = s <= b + 1e-6 && e >= a - 1e-6;
if touches {
(a, b) = (a.min(s), b.max(e));
}
!touches
});
let at = bands.partition_point(|&(s, _)| s < a);
bands.insert(at, (a, b));
(bottom - top - overlap).max(0.0)
}
/// The depth ruler's marks, one at the first line whose depth reaches each multiple of `step`
/// (points): `(line's first byte, the multiple)`. A line that passes several multiples takes the
/// deepest.
fn depth_ticks(depths: &[(usize, f64)], step: f64) -> Vec<(usize, f64)> {
if !(step.is_finite() && step > 0.0) {
return Vec::new();
}
let mut out: Vec<(usize, f64)> = Vec::new();
let mut reached = 0.0;
for &(at, depth) in depths {
if !depth.is_finite() {
continue;
}
let n = (depth / step).floor();
if n > reached {
reached = n;
out.push((at, n * step));
}
}
out
}
/// The depth ruler's marks `(y, depth)` (y ascending) that are drawn: of the marks on one editor
/// row only the deepest, and a mark on a later row only when it is at least `gap` below the one
/// drawn before it.
fn spaced_marks(marks: impl Iterator<Item = (f32, f64)>, gap: f32) -> Vec<(f32, f64)> {
let mut out: Vec<(f32, f64)> = Vec::new();
for mark in marks {
match out.last_mut() {
Some(last) if (mark.0 - last.0).abs() < 0.5 => *last = mark,
Some(last) if mark.0 - last.0 < gap => {}
_ => out.push(mark),
}
}
out
}
/// The editor rows `(top, bottom)` (galley coordinates) that the characters `cis` (ascending) start
/// lines on, in one pass over the rows: at a soft wrap, the row a character starts rather than the
/// end of the row before.
fn start_rows(galley: &egui::Galley, cis: &[usize]) -> Vec<(f32, f32)> {
let mut rows = galley.rows.iter().peekable();
let mut start = 0;
let mut out = Vec::with_capacity(cis.len());
for &ci in cis {
while let Some(row) = rows.peek() {
let end = start + row.char_count_including_newline().0;
if ci < end || rows.len() == 1 {
break;
}
start = end;
rows.next();
}
out.push(rows.peek().map_or((0.0, 0.0), |row| (row.min_y(), row.max_y())));
}
out
}
/// Character indices of the byte offsets `bytes` (ascending) in `text`, for the editor's cursors.
fn char_offsets(text: &str, bytes: impl Iterator<Item = usize>) -> Vec<usize> {
let mut chars = text.char_indices().peekable();
let mut count = 0;
let mut out = Vec::new();
for byte in bytes {
while chars.next_if(|&(i, _)| i < byte).is_some() {
count += 1;
}
out.push(count);
}
out
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
@@ -144,6 +309,234 @@ mod tests {
assert_eq!(super::paragraph_start_offsets(rows.into_iter()), vec![0.0, 60.0, 80.0]);
}
/// The depth runs on through the columns: it never goes back, and the first line of the second
/// column is deeper than the last line of the first.
#[test]
fn depths_run_through_columns() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let text = "Depth ruler line of words. ".repeat(80);
let r = app.run("frame.create", json!({"rect": [72, 72, 372, 272], "content": "text", "text": text, "caret": false})).unwrap();
let (id, sid) = (r["id"].as_u64().unwrap(), designcraft_doc::StoryId(r["story"].as_u64().unwrap()));
app.run("object.textFrameOptions", json!({"ids": [id], "columns": 2, "gutter": 12})).unwrap();
let st = app.session.active().unwrap();
let cs = app.session.cache.get(&st.doc, sid, None);
assert!(cs.overset_at.is_some(), "the fixture overflows its frame");
let depths = super::line_depths(&cs);
let lines: Vec<_> = cs.frames[0].lines.iter().collect();
let second = lines.iter().position(|l| l.column == 1).expect("text reaches the second column");
assert!(second > 0 && depths.len() == lines.len(), "{} lines, {} depths", lines.len(), depths.len());
assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "depth never goes back: {depths:?}");
assert!(depths[second].1 > depths[second - 1].1, "the second column adds to the first: {depths:?}");
}
/// A split paragraph's sub-columns sit side by side: a line beside one already counted adds
/// nothing, whether the sub-columns hold several lines or one each, and the text after the
/// split paragraphs goes on from the deeper sub-column.
#[test]
fn split_paragraph_sub_columns_count_once() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let text =
format!("Opening line.\n{}\nMiddle line.\nTwo short lines set side by side.\nClosing line.", "Split column words go here. ".repeat(12));
let r = app.run("frame.create", json!({"rect": [72, 72, 372, 672], "content": "text", "text": text, "caret": false})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let mut doc = (*app.session.active().unwrap().doc).clone();
for i in [1, 3] {
doc.story_mut(sid).unwrap().paras[i].para.span_columns = Some(designcraft_doc::SpanColumns::Split(2));
}
let cs = designcraft_compose::compose_story(&doc, sid, &designcraft_compose::ComposeOptions::default());
let lines = &cs.frames[0].lines;
let depths = super::line_depths(&cs);
assert!(lines.windows(2).any(|w| w[0].para == 1 && w[1].para == 1 && w[1].baseline < w[0].baseline), "para 1 restarts higher");
let side =
(1..lines.len()).find(|&i| lines[i].para == 3 && lines[i - 1].para == 3 && (lines[i].baseline - lines[i - 1].baseline).abs() < 1e-6);
let para3: Vec<_> = lines.iter().filter(|l| l.para == 3).map(|l| (l.baseline, l.x0)).collect();
let side = side.unwrap_or_else(|| panic!("para 3 sets one line in each sub-column: {para3:?}"));
assert_eq!(depths[side].1, depths[side - 1].1, "the second sub-column's line adds nothing: {depths:?}");
for (i, l) in lines.iter().enumerate().filter(|(_, l)| l.para == 1).skip(1) {
let beside = lines[..i].iter().any(|o| o.para == 1 && o.baseline >= l.baseline - 1e-6);
if beside {
assert_eq!(depths[i].1, depths[i - 1].1, "line {i} sits beside one counted: {depths:?}");
}
}
let last = lines.len() - 1;
let col_top = cs.frames[0].columns[0].y0;
let closing = lines[last].baseline + lines[last].descent - col_top;
assert!(depths[last].1 <= closing + 1e-6, "no deeper than the frame holds it: {} > {closing}", depths[last].1);
assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "{depths:?}");
}
/// Text wrapping on both sides of an object sets one line as two segments on one baseline: the
/// second segment adds nothing to the depth.
#[test]
fn segments_beside_a_wrap_object_share_their_line_depth() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let text = "Words that wrap on both sides of the box in the middle. ".repeat(20);
let r = app.run("frame.create", json!({"rect": [72, 72, 432, 472], "content": "text", "text": text, "caret": false})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let b = app.run("frame.create", json!({"rect": [232, 150, 272, 300], "content": "graphic"})).unwrap();
app.run("object.textWrap", json!({"ids": [b["id"].as_u64().unwrap()], "mode": "boundingBox", "side": "bothSides"})).unwrap();
let st = app.session.active().unwrap();
let cs = app.session.cache.get(&st.doc, sid, None);
let lines = &cs.frames[0].lines;
let depths = super::line_depths(&cs);
let pairs: Vec<usize> = (1..lines.len()).filter(|&i| (lines[i].baseline - lines[i - 1].baseline).abs() < 1e-6).collect();
assert!(!pairs.is_empty(), "the fixture sets lines in two segments");
for i in pairs {
assert_eq!(depths[i].1, depths[i - 1].1, "segment {i} adds no depth: {depths:?}");
}
assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "{depths:?}");
}
/// Space after a paragraph moves the next one down the frame, and the depth goes down with it.
#[test]
fn paragraph_spacing_adds_to_the_depth() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 372, 672], "content": "text", "text": "First.\nSecond.", "caret": false})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let mut doc = (*app.session.active().unwrap().doc).clone();
doc.story_mut(sid).unwrap().paras[0].para.space_after = Some(72.0);
let cs = designcraft_compose::compose_story(&doc, sid, &designcraft_compose::ComposeOptions::default());
let lines = &cs.frames[0].lines;
let depths = super::line_depths(&cs);
assert_eq!(lines.len(), 2);
let moved = (lines[1].baseline + lines[1].descent) - (lines[0].baseline + lines[0].descent);
assert!(moved > 72.0, "the space after moves the second paragraph down: {moved}");
assert!((depths[1].1 - depths[0].1 - moved).abs() < 1e-6, "the depth goes down as far: {depths:?}, {moved}");
}
/// A mark goes at the first line that reaches a multiple; a line that passes several takes only
/// the deepest.
#[test]
fn ticks_go_at_the_first_line_that_reaches_a_multiple() {
let depths = [(0, 10.0), (5, 30.0), (9, 70.0), (12, 100.0), (20, 200.0), (25, f64::NAN)];
assert_eq!(super::depth_ticks(&depths, 60.0), vec![(9, 60.0), (20, 180.0)]);
assert!(super::depth_ticks(&depths, 0.0).is_empty());
assert_eq!(super::char_offsets("aé b", [0, 1, 3, 5].into_iter()), vec![0, 1, 2, 4]);
}
/// Several lines can start on one editor row (narrow columns, large text): only the deepest of
/// their marks is drawn there, and marks on rows closer than a label skip, so labels never
/// overlap and the ones kept stay spread down the ruler.
#[test]
fn marks_on_one_row_show_only_the_deepest_and_keep_their_spacing() {
let marks = [(20.0, 60.0), (20.0, 120.0), (25.0, 180.0), (40.0, 240.0), (60.0, 300.0)];
assert_eq!(super::spaced_marks(marks.into_iter(), 10.0), vec![(20.0, 120.0), (40.0, 240.0), (60.0, 300.0)]);
let close = [(0.0, 60.0), (8.0, 120.0), (16.0, 180.0), (24.0, 240.0)];
assert_eq!(super::spaced_marks(close.into_iter(), 10.0), vec![(0.0, 60.0), (16.0, 180.0)]);
}
/// A line that starts at a soft wrap in the editor is on the wrapped row, not at the end of the
/// row before it, so the overset line and the marks sit beside the text they belong to.
#[test]
fn a_line_starting_at_a_soft_wrap_is_on_the_wrapped_row() {
let ctx = egui::Context::default();
let mut galley = None;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
galley = Some(ui.fonts_mut(|f| f.layout("aaaa bbbb".into(), egui::FontId::proportional(14.0), egui::Color32::WHITE, 40.0)));
});
output.textures_delta.clear();
let galley = galley.expect("laid out");
assert!(galley.rows.len() >= 2, "the fixture wraps: {} rows", galley.rows.len());
let rows = super::start_rows(&galley, &[0, 4, 5, 9, 50]);
let (first, second) = ((galley.rows[0].min_y(), galley.rows[0].max_y()), (galley.rows[1].min_y(), galley.rows[1].max_y()));
assert_eq!(rows, vec![first, first, second, second, (galley.rows.last().unwrap().min_y(), galley.rows.last().unwrap().max_y())]);
}
/// View › Story Editor › Show/Hide Depth Ruler: the ruler sits between the style column and the
/// text, which moves right by the ruler's width; the setting is remembered.
#[test]
fn depth_ruler_shows_and_hides_from_the_view_menu() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ruler story", "caret": false})).unwrap();
app.run("app.storyEditor", json!({"story": r["story"].as_u64().unwrap()})).unwrap();
assert!(app.ui.story_editor_depth_ruler, "shown by default");
let mut h = crate::test_window::open(app, egui::vec2(1440.0, 900.0));
h.run_steps(2);
let text_x = |h: &egui_kittest::Harness<'_, crate::test_window::Window>| {
h.get_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.value().as_deref() == Some("Ruler story")).rect().min.x
};
let with = text_x(&h);
h.state_mut().app.run("view.storyEditorDepthRuler", json!({})).unwrap();
h.run_steps(2);
let without = text_x(&h);
assert!((with - without - super::RULER_WIDTH).abs() < 10.0, "the text moves by the ruler: {with} vs {without}");
let app = &h.state().app;
assert_eq!(crate::menus::checked(app, "view.storyEditorDepthRuler", &json!({})), Some(false));
let saved = serde_json::to_vec(&app.ui).unwrap();
assert!(!crate::UiState::from_preferences_json(&saved).unwrap().story_editor_depth_ruler);
}
#[test]
fn depth_marks_wait_for_composition_after_editing() {
fn marks(shape: &egui::epaint::Shape, tick: egui::Color32) -> usize {
match shape {
egui::epaint::Shape::Vec(shapes) => shapes.iter().map(|s| marks(s, tick)).sum(),
egui::epaint::Shape::LineSegment { points, stroke }
if stroke.color == super::OVERSET_RED || (stroke.color == tick && (points[1].x - points[0].x - 8.0).abs() < 0.01) =>
{
1
}
_ => 0,
}
}
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let text = "First line\nSecond line\nThird line\n".repeat(4);
let r = app.run("frame.create", json!({"rect": [72, 72, 372, 172], "content": "text", "text": text, "caret": false})).unwrap();
app.run("app.storyEditor", json!({"story": r["story"].as_u64().unwrap()})).unwrap();
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(900.0, 700.0))
.with_accessibility_check(false)
.build_ui_state(|ui, app| super::show(app, ui.ctx()), app);
h.run_steps(3);
let tick = crate::theme::Tokens::get(&h.ctx).ruler_tick;
let count = |h: &egui_kittest::Harness<'_, DesignApp>| h.output().shapes.iter().map(|s| marks(&s.shape, tick)).sum::<usize>();
assert!(count(&h) >= 2, "the fixture paints ruler ticks and an overset line");
h.get_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).focus();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Home);
h.run_steps(2);
h.event(egui::Event::Text("Inserted line\n".into()));
h.step();
assert_eq!(story_text(h.state(), 0), format!("Inserted line\n{text}"));
assert_eq!(count(&h), 0, "the edit frame must not map old composition offsets to new text");
h.step();
assert!(count(&h) >= 2, "the next frame paints the updated composition");
}
/// The Show/Hide Depth Ruler menu label is translated in every interface language.
#[test]
fn depth_ruler_menu_label_is_translated_in_every_language() {
for (lang, _) in crate::i18n::LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) {
assert_ne!(crate::i18n::tr(lang, "Show/Hide Depth Ruler"), "Show/Hide Depth Ruler", "{lang}");
}
}
/// Showing or hiding the depth ruler keeps the text's focus and caret: typing after the toggle
/// still reaches the story.
#[test]
fn depth_ruler_toggle_keeps_the_text_focused() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ruler story", "caret": false})).unwrap();
app.run("app.storyEditor", json!({"story": r["story"].as_u64().unwrap()})).unwrap();
let mut h = crate::test_window::open(app, egui::vec2(1440.0, 900.0));
h.get_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.value().as_deref() == Some("Ruler story")).focus();
h.run_steps(2);
for (toggle, typed) in [("!", "Ruler story!"), ("?", "Ruler story!?")] {
h.state_mut().app.run("view.storyEditorDepthRuler", json!({})).unwrap();
h.run_steps(2);
h.event(egui::Event::Text(toggle.into()));
h.run_steps(2);
assert_eq!(story_text(&h.state().app, 0), typed);
}
}
/// Story ids are counted per document: typing in the editor after switching documents edited
/// the other document's story with the same id.
#[test]
+1 -1
View File
@@ -145,7 +145,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
let menu = panels::font_menu(app);
let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) };
let field = widgets::dropdown(ui, &shown, 150.0);
if let Some(f) = panels::font_popup(app, &field, &menu, &fam) {
if let Some(f) = panels::font_popup(app, ui, &field, &menu, &fam) {
panels::apply_font(app, &f);
}
let styles = fonts.styles(&fam);
+1 -1
View File
@@ -189,7 +189,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User
Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
Convert Columns, Object ▸ Interactive ▸ Convert to (form fields),
View ▸ Extras badges and Story Editor options. Pages panel ▸ Parent Options (Based on Parent is
View ▸ Extras badges and Story Editor options (Show/Hide Depth Ruler is there). Pages panel ▸ Parent Options (Based on Parent is
the `layout.parents.options` command only); overriding a base parent's items on a parent spread
itself (overrides work on document pages only).
- **Estimate:** 40–70 h (most are thin wrappers over existing engine features).
+3 -3
View File
@@ -73,13 +73,13 @@ The release packages ship `designcraft-cli` alongside the desktop app, so no bui
| Install | CLI |
|---|---|
| Windows (MSI) | `C:\Program Files\DesignCraft\designcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\DesignCraft\designcraft-cli.exe` by default (`C:\Program Files\ArtCraft\DesignCraft\designcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` |
| Linux (deb, rpm) | `/usr/bin/designcraft-cli` |
| macOS | the separate `designcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
```sh
# Windows, default install folder
claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" mcp
# Windows (PowerShell), default install folder
claude mcp add designcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\DesignCraft\designcraft-cli.exe" mcp
# Linux, or macOS with the CLI unzipped onto PATH
claude mcp add designcraft -- designcraft-cli mcp
```
+1 -1
View File
@@ -1,7 +1,7 @@
# Shared PDF backend
DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the six Hayro reader crates (with
`hayro-cmap` and `hayro-postscript`) to craft-libs revision `b926b5c03e256c86ca10934f58d03f628810b5b7`. `craft-ui`,
`hayro-cmap` and `hayro-postscript`) to craft-libs revision `92d392cff132eaf53e13afff77f38b5c11b73aa0`. `craft-ui`,
`craft-print` and the other craft-libs crates are pinned to the same revision. The reader migration is reviewed on top of the separate writer change.
## Writing
+1 -1
View File
@@ -348,7 +348,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions.
- Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally.
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).
+1 -1
View File
@@ -50,7 +50,7 @@ basics of editing fail for too many users on Windows and Linux.
| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies |
| Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) |
| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |
| Story Editor | ✓ | ✓ | ~ | depth ruler and View › Story Editor › Show/Hide Depth Ruler (`story_editor.rs` `depth_ruler_shows_and_hides_from_the_view_menu`); hiding or resizing the style-name column missing |
| Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | |
| Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) |
| UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | |
+35
View File
@@ -0,0 +1,35 @@
# Windows installation policy checks
New installations default to the current account under `Programs\ArtCraft\<App>`.
The first setup page offers installation for everyone. Scope is selected before duplicate
checks and directory costing. An update uses the existing context's remembered exact folder;
`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the
chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`.
A cross-context copy is refused; changing context means uninstalling the old copy first.
App registration follows the selected context. Existing optional shortcuts and file-handler
choices stay app-owned. No new cleanup operation or model/settings migration is introduced.
Packaged resources remain package-owned; separately downloaded shared models are not caches
and are outside this installer's ownership.
## Repeatable metadata tests
On Windows with WiX 5.0.2 and its matching UI/Util extensions:
```powershell
./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests
```
The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It
uses the actual package source and compiled database. A safe Windows Installer session checks
24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing,
upgrade identity and the original registration inventory recorded in `install-policy.json`.
The release packaging script runs the compiled checker before signing. Packaging CI runs the
small fixture builds without compiling the Rust application.
Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks
does not prove real installation, elevation, legacy-version upgrades, registry writes, optional
shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real
payload in an authorised clean Windows test environment. Cross-architecture metadata is not
execution evidence on those architectures. Existing open installer/association PRs must be
reconciled before publication. Nothing here changes macOS/Linux native packaging conventions.
+370
View File
@@ -0,0 +1,370 @@
<#
.SYNOPSIS
Check the compiled installation policy without installing any product.
.DESCRIPTION
Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom
actions are exercised. No AppSearch, install, repair, removal or file-writing action runs.
#>
param([Parameter(Mandatory = $true)] [string] $MsiPath)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json
function Read-Rows($Database, [string] $Sql, [int] $Columns) {
$view = $Database.OpenView($Sql)
try {
[void]$view.Execute()
while ($null -ne ($record = $view.Fetch())) {
try {
$values = @(for ($i = 1; $i -le $Columns; $i++) {
$record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i))
})
,$values
} finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) }
}
} finally {
[void]$view.Close()
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view)
}
}
function Set-SessionProperty($Session, [string] $Name, [string] $Value) {
[void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value))
}
function Get-SessionProperty($Session, [string] $Name) {
$Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name))
}
function Get-PackageTemplate($Database) {
# Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033".
$summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0))
try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) }
finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) }
}
$installer = $null; $database = $null; $session = $null
try {
$installer = New-Object -ComObject WindowsInstaller.Installer
$file = (Resolve-Path -LiteralPath $MsiPath).Path
$database = $installer.OpenDatabase($file, 0)
$session = $installer.OpenPackage($file, 1)
$properties = @{}
foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] }
$secureProperties = @($properties['SecureCustomProperties'] -split ';')
if ('INSTALLFOLDER' -notin $secureProperties) {
throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).'
}
if ($policy.mode -eq 'perUser') {
if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' }
if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' }
$dirs = @{}
foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) }
if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or
$dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') {
throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.'
}
foreach ($explicit in @('', 'F:\Chosen exact launcher')) {
Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher'
Set-SessionProperty $session 'INSTALLFOLDER' $explicit
$row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0]
if ($session.EvaluateCondition($row[0]) -eq 1) {
if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' }
}
$expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' }
if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' }
}
$rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' }
foreach ($entry in $policy.registry) {
if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) {
throw 'Launcher registration or shortcut preference changed unexpectedly.'
}
}
Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.'
return
}
if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') {
throw 'Policy regression: new packages must default to the per-user dual-scope context.'
}
if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' }
$directories = @{}
foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) {
$directories[$row[0]] = @($row[1], $row[2])
}
if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or
$directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' }
$programs = $directories['CraftSuiteFolder'][0]
if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] }
if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') {
throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.'
}
$guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 |
Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' })
if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' }
$cases = 0
foreach ($scope in @('', '2', '1')) {
foreach ($user in @('', 'D:\Existing user app')) {
foreach ($machine in @('', 'E:\Existing machine app')) {
foreach ($installed in @('', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'Installed' $installed
Set-SessionProperty $session 'CRAFT_USER_DIR' $user
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine
$allowed = $true
foreach ($guard in $guards) {
$value = $session.EvaluateCondition($guard[0])
if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' }
if ($value -eq 0) { $allowed = $false }
}
$opposite = if ($scope -eq '1') { $user } else { $machine }
$expected = [bool]$installed -or -not [bool]$opposite
if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." }
$cases++
}
}
}
}
# Execute the actual authored type-51 actions in a safe session, not copied resolver logic.
foreach ($scope in @('', '2', '1')) {
foreach ($explicit in @('', 'F:\Chosen exact app')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'INSTALLFOLDER' $explicit
Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app'
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
$action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' }
# Evaluate the compiled condition, so the test exercises it instead of calling the action directly.
$restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0]
$selected = $session.EvaluateCondition($restore[0]) -eq 1
if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." }
if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' }
$expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' }
if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' }
}
}
$ui = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] }
if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and
$ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and
$ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and
$ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'] -and
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreMachineFolder'] -and $ui['CraftRestoreMachineFolder'] -lt $ui['CostInitialize'])) {
throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.'
}
# Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice.
# The wizard therefore sets the new-install folder itself, and only in the wizard.
$defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' })
if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' }
foreach ($row in $defaults) {
if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." }
}
if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) {
throw 'Silent installs must not use the wizard-only default folder actions.'
}
# The machine default comes from the environment of the package architecture, not from a folder name.
# x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root.
$machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') {
if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles }
} elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
$machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\'
foreach ($scope in @('1', '')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'INSTALLFOLDER' ''
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' ''
Set-SessionProperty $session 'CRAFT_USER_DIR' ''
foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$folder = Get-SessionProperty $session 'INSTALLFOLDER'
$wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' }
if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." }
}
# Replay the wizard's compiled folder actions in sequence order, as the full UI runs them. A remembered
# folder must win over the new-install default, and a folder the user typed must survive both.
$uiFolderActions = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 |
Where-Object { $_[0] -match '^Craft(Restore|Default)' } | Sort-Object { [int]$_[1] })
$userDefault = (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\'
foreach ($case in @(
@('1', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'E:\Remembered machine app'),
@('', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'D:\Remembered user app'),
@('1', '', '', '', $machineFolder),
@('', '', '', '', $userDefault),
@('1', 'D:\Remembered user app', 'E:\Remembered machine app', 'F:\Chosen exact app', 'F:\Chosen exact app'))) {
Set-SessionProperty $session 'Installed' ''
Set-SessionProperty $session 'ALLUSERS' $case[0]
Set-SessionProperty $session 'CRAFT_USER_DIR' $case[1]
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $case[2]
Set-SessionProperty $session 'INSTALLFOLDER' $case[3]
foreach ($row in $uiFolderActions) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$folder = Get-SessionProperty $session 'INSTALLFOLDER'
if ($folder -ine $case[4]) { throw "Full-UI folder for ALLUSERS='$($case[0])' is '$folder', expected '$($case[4])'." }
}
$execute = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] }
foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) {
if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) {
throw 'Silent updates must restore the folder before directory costing.'
}
}
foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) {
if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) {
throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.'
}
}
# AppSearch itself never runs in this safe session, so check the compiled searches and then run the
# property actions that turn the search results into the remembered folder, once per scope.
$appSearch = @{}
foreach ($row in (Read-Rows $database 'SELECT `Property`, `Signature_` FROM `AppSearch`' 2)) { $appSearch[$row[0]] = $row[1] }
$locators = @{}
foreach ($row in (Read-Rows $database 'SELECT `Signature_`, `Root`, `Key`, `Name`, `Type` FROM `RegLocator`' 5)) { $locators[$row[0]] = $row }
$appPaths = '^Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\[^\\]+\.exe$'
# Suffix, how to match the key, key, value name, locator type (2 = raw value, 1 = file; the 64-bit flag is masked off).
$searchContract = @(
@('DIR', 'exact', "Software\$($policy.name)", 'InstallDir', '2'),
@('FOLDER', 'exact', "Software\$($policy.name)", 'InstallFolder', '2'),
@('PATH', 'regex', $appPaths, 'Path', '2'),
@('EXEFOLDER', 'regex', $appPaths, '', '1'),
@('EXE', 'regex', $appPaths, '', '1'))
$resolverCases = @(
@{ name = 'every record, DIR wins'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Dir\' },
@{ name = 'FOLDER wins without DIR'; dir = ''; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Folder\' },
@{ name = 'PATH wins without DIR, FOLDER'; dir = ''; folder = ''; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Path\' },
@{ name = 'legacy exe folder is last'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Exe\' },
@{ name = 'legacy folder needs the exe'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = ''; existing = ''; expected = '' },
@{ name = 'a preset folder is kept'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = 'P:\Preset\'; expected = 'P:\Preset\' })
foreach ($scopeSpec in @(@('U', '1', 'CRAFT_USER_DIR'), @('M', '2', 'CRAFT_MACHINE_DIR'))) {
$letter = $scopeSpec[0]; $target = $scopeSpec[2]
foreach ($item in $searchContract) {
$property = "CRAFT_${letter}_$($item[0])"
$locator = if ($appSearch.ContainsKey($property)) { $locators[$appSearch[$property]] } else { $null }
$keyOk = $null -ne $locator -and $(if ($item[1] -eq 'exact') { $locator[2] -eq $item[2] } else { $locator[2] -match $item[2] })
if (-not $keyOk -or $locator[1] -ne $scopeSpec[1] -or $locator[3] -ne $item[3] -or ([int]$locator[4] -band 3) -ne [int]$item[4]) {
throw "Compiled search for $property is missing or points at the wrong registry location."
}
}
$chain = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallExecuteSequence`' 3 |
Where-Object { $_[0] -match "^Craft${letter}From" } | Sort-Object { [int]$_[1] })
$order = @($chain | ForEach-Object { $_[0] }) -join ','
if ($order -ne "Craft${letter}FromDIR,Craft${letter}FromFOLDER,Craft${letter}FromPATH,Craft${letter}FromLegacyExe") {
throw "Remembered-folder resolvers for scope $letter run in the wrong order: $order"
}
foreach ($case in $resolverCases) {
Set-SessionProperty $session $target $case.existing
Set-SessionProperty $session "CRAFT_${letter}_DIR" $case.dir
Set-SessionProperty $session "CRAFT_${letter}_FOLDER" $case.folder
Set-SessionProperty $session "CRAFT_${letter}_PATH" $case.path
Set-SessionProperty $session "CRAFT_${letter}_EXEFOLDER" $case.exeFolder
Set-SessionProperty $session "CRAFT_${letter}_EXE" $case.exe
foreach ($row in $chain) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$got = Get-SessionProperty $session $target
if ($got -ne $case.expected) { throw "Resolver chain for scope $letter, case '$($case.name)': got '$got', expected '$($case.expected)'." }
}
}
# Switching the first-page choice must reset BOTH properties, including a back-and-forth change.
$events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4)
# Model the full-UI upgrade entry with an existing machine-wide copy. Run
# the actual preference action, then the compiled Next events before discovery.
Set-SessionProperty $session 'ALLUSERS' '2'
Set-SessionProperty $session 'CRAFT_SCOPE' 'user'
Set-SessionProperty $session 'CRAFT_USER_DIR' ''
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
$preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0]
if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or
(Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') {
throw 'Full-UI machine upgrades must initially select the existing machine context.'
}
foreach ($choice in @('machine','user','machine','user')) {
Set-SessionProperty $session 'CRAFT_SCOPE' $choice
foreach ($event in $events) {
if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue }
$property = $Matches[1]
if ($session.EvaluateCondition($event[2]) -eq 1) {
Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] })
}
}
$allUsers = Get-SessionProperty $session 'ALLUSERS'
$perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER'
if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or
($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) {
throw 'Scope selector does not reset the requested installation context.'
}
}
$actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4)
$savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' })
foreach ($option in $savedOptions) {
if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' }
$number = [regex]::Match($option[0], '[0-9]+').Value
foreach ($scope in @('2','1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice'
Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice'
Set-SessionProperty $session $option[1] ''
$action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' })
if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' }
$expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' }
if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' }
}
}
# Follow the compiled control conditions, not the source's intended appearance.
$controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5)
$conditions = @()
if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) {
$conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4)
}
function Test-ControlVisible($Control) {
$visible = ([int]$Control[3] -band 1) -ne 0
foreach ($condition in $conditions) {
if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) {
if ($condition[2] -eq 'Show') { $visible = $true }
if ($condition[2] -eq 'Hide') { $visible = $false }
}
}
return $visible
}
foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) {
$plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') })
if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' }
foreach ($scope in @('', '2', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) {
throw 'The elevation shield must follow the selected scope.'
}
}
$dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1]
$shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
$plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' }
}
foreach ($scope in @('', '2', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) {
if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' }
}
}
if ($policy.id -in 'deckcraft','filmcraft','soundcraft') {
foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) {
$dialog = $route[0]; $control = $route[1]
$routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2)
Set-SessionProperty $session 'Installed' ''
$destination = ''
foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } }
if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' }
}
}
$registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' }
if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) {
throw 'HKCR keys must be relative to Classes.'
}
foreach ($entry in $policy.registry) {
$found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] })
if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" }
}
$installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' })
if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' }
Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed."
} finally {
foreach ($com in @($session, $database, $installer)) {
if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) }
}
}
+18 -33
View File
@@ -1,30 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
DesignCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
wix build designcraft.wxs installer-ui.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, `.designcraft` owned by
DesignCraft, and DesignCraft offered under "Open with" for IDML (without taking over the user's
default app for it).
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<?ifndef HasCraftFontLicences ?>
<?define HasCraftFontLicences=0 ?>
<?endif ?>
<Package
Name="DesignCraft"
Manufacturer="Learning Machines LLC"
Version="$(var.Version)"
UpgradeCode="49E87D30-BB68-4896-AA1D-F45C833CFC3C"
Scope="perMachine"
Compressed="yes"
Language="1033">
<?endif?>
<Package Name="DesignCraft" Manufacturer="Learning Machines LLC" Version="$(var.Version)" UpgradeCode="49E87D30-BB68-4896-AA1D-F45C833CFC3C" Scope="perUserOrMachine" Compressed="yes" Language="1033">
<SummaryInformation Description="DesignCraft page layout and publishing" Manufacturer="Learning Machines LLC" />
<?include $(sys.SOURCEFILEDIR)install-policy.wxi?>
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
<MajorUpgrade AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of DesignCraft is already installed." />
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="A newer version of DesignCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="DesigncraftIcon.ico" SourceFile="$(var.IconPath)" />
@@ -36,14 +20,16 @@
<!-- Shared native setup UI, including install/uninstall completion. -->
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="DesignCraft" />
<Directory Id="CraftSuiteFolder" Name="ArtCraft">
<Directory Id="INSTALLFOLDER" Name="DesignCraft" />
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="DesignCraft" Level="1">
<?if $(var.HasCraftFontLicences) = 1 ?>
<ComponentGroupRef Id="CraftFontLicences" />
<?endif ?>
<?endif?>
<ComponentGroupRef Id="DesigncraftFiles" />
<ComponentGroupRef Id="CraftInstallerSettings" />
</Feature>
@@ -56,16 +42,14 @@
<ComponentGroup Id="CraftFontLicences" Directory="INSTALLFOLDER">
<Files Include="$(var.DocsDir)\**" />
</ComponentGroup>
<?endif ?>
<?endif?>
<ComponentGroup Id="DesigncraftFiles" Directory="INSTALLFOLDER">
<Component Id="DesigncraftNotice">
<File Id="DesigncraftNoticeFile" Source="$(var.BinDir)\NOTICE" KeyPath="yes" />
</Component>
<Component Id="DesigncraftApp" Guid="DE018EF3-EE87-4E36-9F76-0B7B060A195E">
<File Id="DesigncraftExe" Source="$(var.BinDir)\designcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="DesignCraft"
Description="DesignCraft page layout and publishing" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="DesigncraftIcon.ico" IconIndex="0" />
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="DesignCraft" Description="DesignCraft page layout and publishing" WorkingDirectory="INSTALLFOLDER" Advertise="yes" Icon="DesigncraftIcon.ico" IconIndex="0" />
</File>
<!-- .designcraft: DesignCraft's own format; DesignCraft is its default app. -->
@@ -76,15 +60,16 @@
</ProgId>
<!-- IDML: offered under "Open with", never forced as the default. -->
<ProgId Id="Designcraft.Idml" Description="IDML Document" Icon="DesigncraftExe" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\Classes\Designcraft.Idml\shell\open\command" Value="&quot;[#DesigncraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.idml\OpenWithProgids" Name="Designcraft.Idml" Value="" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Classes\Designcraft.Idml\shell\open\command" Value="&quot;[#DesigncraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Classes\.idml\OpenWithProgids" Name="Designcraft.Idml" Value="" Type="string" />
<!-- "Open with" app list and the Run dialog (Win+R "designcraft"). -->
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe" Name="FriendlyAppName" Value="DesignCraft" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe\shell\open\command" Value="&quot;[#DesigncraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe\SupportedTypes" Name=".designcraft" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\designcraft.exe\SupportedTypes" Name=".idml" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Value="[#DesigncraftExe]" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Classes\Applications\designcraft.exe" Name="FriendlyAppName" Value="DesignCraft" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Classes\Applications\designcraft.exe\shell\open\command" Value="&quot;[#DesigncraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Classes\Applications\designcraft.exe\SupportedTypes" Name=".designcraft" Value="" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Classes\Applications\designcraft.exe\SupportedTypes" Name=".idml" Value="" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Value="[#DesigncraftExe]" Type="string" />
<RegistryValue Root="HKMU" Key="Software\DesignCraft" Name="InstallDir" Value="[INSTALLFOLDER]" Type="string" />
</Component>
<Component Id="DesigncraftCli">
+62
View File
@@ -0,0 +1,62 @@
{
"id": "designcraft",
"name": "DesignCraft",
"upgradeCode": "49E87D30-BB68-4896-AA1D-F45C833CFC3C",
"sources": [
"designcraft.wxs",
"installer-ui.wxs"
],
"icon": "assets/app-icon/designcraft.ico",
"localization": [],
"extensions": [],
"payloads": [
"NOTICE",
"designcraft-cli.exe",
"designcraft.exe"
],
"registry": [
[
"-1",
"Software\\Classes\\Designcraft.Idml\\shell\\open\\command",
"",
"\"[#DesigncraftExe]\" \"%1\""
],
[
"-1",
"Software\\Classes\\.idml\\OpenWithProgids",
"Designcraft.Idml",
""
],
[
"-1",
"Software\\Classes\\Applications\\designcraft.exe",
"FriendlyAppName",
"DesignCraft"
],
[
"-1",
"Software\\Classes\\Applications\\designcraft.exe\\shell\\open\\command",
"",
"\"[#DesigncraftExe]\" \"%1\""
],
[
"-1",
"Software\\Classes\\Applications\\designcraft.exe\\SupportedTypes",
".designcraft",
""
],
[
"-1",
"Software\\Classes\\Applications\\designcraft.exe\\SupportedTypes",
".idml",
""
],
[
"-1",
"Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\designcraft.exe",
"",
"[#DesigncraftExe]"
]
],
"mode": "dualScope"
}
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<Include xmlns="http://wixtoolset.org/schemas/v4/wxs">
<!-- Choose scope before launch checks and directory costing. Never move an existing copy. -->
<!-- Only for me leaves ALLUSERS unset. Publishing ALLUSERS=2 here makes FindRelatedProducts treat the run as
per-machine and skip the existing per-user copy, so an interactive upgrade installs beside it
(two registered products, and uninstall leaves files behind). Checked with real msiexec runs. -->
<Property Id="INSTALLFOLDER" Secure="yes" />
<Property Id="CRAFT_SCOPE" Value="user" Secure="yes" />
<Property Id="CRAFT_U_DIR" Secure="yes"><RegistrySearch Id="CRAFT_U_DIRSearch" Root="HKCU" Key="Software\DesignCraft" Name="InstallDir" Type="raw" /></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromDIR" Value="[CRAFT_U_DIR]" After="AppSearch" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_DIR" />
<Property Id="CRAFT_U_FOLDER" Secure="yes"><RegistrySearch Id="CRAFT_U_FOLDERSearch" Root="HKCU" Key="Software\DesignCraft" Name="InstallFolder" Type="raw" /></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromFOLDER" Value="[CRAFT_U_FOLDER]" After="CraftUFromDIR" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_FOLDER" />
<Property Id="CRAFT_U_PATH" Secure="yes"><RegistrySearch Id="CRAFT_U_PATHSearch" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Name="Path" Type="raw" /></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromPATH" Value="[CRAFT_U_PATH]" After="CraftUFromFOLDER" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_PATH" />
<Property Id="CRAFT_U_EXEFOLDER" Secure="yes"><RegistrySearch Id="CRAFT_ULegacyFolder" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Type="file" /></Property>
<Property Id="CRAFT_U_EXE" Secure="yes"><RegistrySearch Id="CRAFT_ULegacyExe" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Type="file"><FileSearch Id="CRAFT_ULegacyFile" Name="designcraft.exe" /></RegistrySearch></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromLegacyExe" Value="[CRAFT_U_EXEFOLDER]" After="CraftUFromPATH" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_EXEFOLDER AND CRAFT_U_EXE" />
<Property Id="CRAFT_USER_DIR" Secure="yes" />
<Property Id="CRAFT_M_DIR" Secure="yes"><RegistrySearch Id="CRAFT_M_DIRSearch" Root="HKLM" Key="Software\DesignCraft" Name="InstallDir" Type="raw" /></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromDIR" Value="[CRAFT_M_DIR]" After="CraftUFromLegacyExe" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_DIR" />
<Property Id="CRAFT_M_FOLDER" Secure="yes"><RegistrySearch Id="CRAFT_M_FOLDERSearch" Root="HKLM" Key="Software\DesignCraft" Name="InstallFolder" Type="raw" /></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromFOLDER" Value="[CRAFT_M_FOLDER]" After="CraftMFromDIR" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_FOLDER" />
<Property Id="CRAFT_M_PATH" Secure="yes"><RegistrySearch Id="CRAFT_M_PATHSearch" Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Name="Path" Type="raw" /></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromPATH" Value="[CRAFT_M_PATH]" After="CraftMFromFOLDER" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_PATH" />
<Property Id="CRAFT_M_EXEFOLDER" Secure="yes"><RegistrySearch Id="CRAFT_MLegacyFolder" Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Type="file" /></Property>
<Property Id="CRAFT_M_EXE" Secure="yes"><RegistrySearch Id="CRAFT_MLegacyExe" Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\designcraft.exe" Type="file"><FileSearch Id="CRAFT_MLegacyFile" Name="designcraft.exe" /></RegistrySearch></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromLegacyExe" Value="[CRAFT_M_EXEFOLDER]" After="CraftMFromPATH" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_EXEFOLDER AND CRAFT_M_EXE" />
<Property Id="CRAFT_MACHINE_DIR" Secure="yes" />
<SetProperty Id="CRAFT_SCOPE" Action="CraftPreferMachineScope" Value="machine" After="CraftMFromLegacyExe" Sequence="ui" Condition="ALLUSERS = 1 OR (CRAFT_MACHINE_DIR AND NOT CRAFT_USER_DIR)" />
<SetProperty Id="INSTALLFOLDER" Action="CraftRestoreUserFolder" Value="[CRAFT_USER_DIR]" Before="CostInitialize" Sequence="both" Condition="NOT INSTALLFOLDER AND ALLUSERS &lt;&gt; 1 AND CRAFT_USER_DIR" />
<!-- The wizard can change the context after Windows Installer has already resolved Program Files for the first one.
Give the new-install folder explicitly so Everyone never lands in the profile and Only for me never lands in Program Files. -->
<?if $(sys.BUILDARCH) = x86 ?>
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultMachineFolder" Value="[%ProgramFiles(x86)]\ArtCraft\DesignCraft\" After="CraftRestoreUserFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1 AND VersionNT64" />
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultMachineFolder32" Value="[%ProgramFiles]\ArtCraft\DesignCraft\" After="CraftDefaultMachineFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1 AND NOT VersionNT64" />
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultUserFolder" Value="[LocalAppDataFolder]Programs\ArtCraft\DesignCraft\" After="CraftDefaultMachineFolder32" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS &lt;&gt; 1" />
<?else?>
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultMachineFolder" Value="[%ProgramW6432]\ArtCraft\DesignCraft\" After="CraftRestoreUserFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1" />
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultUserFolder" Value="[LocalAppDataFolder]Programs\ArtCraft\DesignCraft\" After="CraftDefaultMachineFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS &lt;&gt; 1" />
<?endif?>
<SetProperty Id="INSTALLFOLDER" Action="CraftRestoreMachineFolder" Value="[CRAFT_MACHINE_DIR]" Before="CraftRestoreUserFolder" Sequence="both" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1 AND CRAFT_MACHINE_DIR" />
<Launch Condition="Installed OR ALLUSERS &lt;&gt; 1 OR NOT CRAFT_USER_DIR" Message="This app is already installed for your account. Choose Only for me to update it, or uninstall that copy before changing who it is installed for." />
<Launch Condition="Installed OR ALLUSERS = 1 OR NOT CRAFT_MACHINE_DIR" Message="This app is already installed for everyone. Choose Everyone to update it, or pass ALLUSERS=1 to msiexec. Uninstall that copy before changing who it is installed for." />
<UI>
<TextStyle Id="CraftScopeText" FaceName="Segoe UI" Size="9" />
<TextStyle Id="CraftScopeTitle" FaceName="Segoe UI" Size="12" Bold="yes" />
<Dialog Id="CraftScopeDlg" Width="370" Height="220" Title="[ProductName] Setup" NoMinimize="yes">
<Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="30" Text="{\CraftScopeTitle}Who should use [ProductName]?" />
<Control Id="Info" Type="Text" X="20" Y="50" Width="330" Height="30" Text="{\CraftScopeText}An update keeps the existing folder. New copies use the ArtCraft folder under Programs." />
<Control Id="Scope" Type="RadioButtonGroup" X="20" Y="88" Width="330" Height="60" Property="CRAFT_SCOPE">
<RadioButtonGroup Property="CRAFT_SCOPE">
<RadioButton Value="user" X="0" Y="0" Width="330" Height="20" Text="Only for me (no administrator permission needed)" />
<RadioButton Value="machine" X="0" Y="30" Width="330" Height="20" Text="Everyone on this computer (asks an administrator)" />
</RadioButtonGroup>
</Control>
<Control Id="Next" Type="PushButton" X="214" Y="182" Width="66" Height="20" Default="yes" Text="Next">
<Publish Property="ALLUSERS" Value="1" Order="1" Condition="CRAFT_SCOPE = &quot;machine&quot;" />
<Publish Property="ALLUSERS" Value="{}" Order="2" Condition="CRAFT_SCOPE = &quot;user&quot;" />
<Publish Property="MSIINSTALLPERUSER" Value="{}" Order="3" Condition="CRAFT_SCOPE = &quot;machine&quot;" />
<Publish Property="MSIINSTALLPERUSER" Value="1" Order="4" Condition="CRAFT_SCOPE = &quot;user&quot;" />
<Publish Event="EndDialog" Value="Return" Order="5" />
</Control>
<Control Id="Cancel" Type="PushButton" X="290" Y="182" Width="60" Height="20" Cancel="yes" Text="Cancel"><Publish Event="EndDialog" Value="Exit" /></Control>
</Dialog>
</UI>
<InstallUISequence>
<Show Dialog="CraftScopeDlg" After="CraftPreferMachineScope" Condition="NOT Installed AND NOT REMOVE AND UILevel = 5" />
<!-- Related-product discovery runs once, so full UI must choose its context first. -->
<FindRelatedProducts After="CraftScopeDlg" />
</InstallUISequence>
</Include>
+36 -39
View File
@@ -1,18 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by craft-installer for designcraft. Edit the template in craft-libs, then regenerate. -->
<!-- Adapted from PdfCraft installer-ui.wxs (a6bc63a), GridCraft gridcraft.wxs (5b15827),
and EffectCraft installer-ui.wxs (1b4d47f).
Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors.
Copyright (c) 2026 ArtCraft Team and the GridCraft contributors.
Copyright (c) 2026 ArtCraft Team and the EffectCraft contributors.
Licensed under MIT OR Apache-2.0; see the repository's LICENSE-MIT and LICENSE-APACHE. -->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Fragment>
<!-- Windows Installer populates files-in-use lists at runtime. Keep their table even
though these dialogs have no static ListItem rows (the GridCraft UI does the same). -->
<EnsureTable Id="ListBox" />
<!-- Reuse the existing package icon as a fallback; MSI supplies its standard message icon. -->
<Binary Id="CraftInstallerErrorIcon" SourceFile="$(var.IconPath)" />
<Binary Id="CraftErrorIcon" SourceFile="$(var.IconPath)" />
<UI Id="CraftInstallerUI">
@@ -22,7 +15,7 @@
<Property Id="ErrorDialog" Value="CraftInstallerErrorDlg" />
<Dialog Id="CraftInstallerErrorDlg" Width="320" Height="120" Title="[ProductName] Setup" ErrorDialog="yes" NoMinimize="yes">
<Control Id="ErrorText" Type="Text" X="60" Y="15" Width="245" Height="65" TabSkip="no" />
<Control Id="ErrorIcon" Type="Icon" X="15" Y="15" Width="32" Height="32" FixedSize="yes" IconSize="32" TabSkip="yes" Text="CraftInstallerErrorIcon" />
<Control Id="ErrorIcon" Type="Icon" X="15" Y="15" Width="32" Height="32" FixedSize="yes" IconSize="32" TabSkip="yes" Text="CraftErrorIcon" />
<Control Id="A" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Abort" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorAbort" />
</Control>
@@ -46,8 +39,7 @@
</Control>
</Dialog>
<Dialog Id="FilesInUse" Width="320" Height="190" Title="[ProductName] Setup" NoMinimize="yes">
<Control Id="Description" Type="Text" X="15" Y="15" Width="290" Height="35"
Text="Close the applications listed below, then choose Retry. Ignore may require a restart." />
<Control Id="Description" Type="Text" X="15" Y="15" Width="290" Height="35" Text="Close the applications listed below, then choose Retry. Ignore may require a restart." />
<Control Id="Files" Type="ListBox" X="15" Y="55" Width="290" Height="85" Property="FileInUseProcess" />
<Control Id="Retry" Type="PushButton" X="95" Y="155" Width="65" Height="20" Default="yes" Text="Retry">
<Publish Event="EndDialog" Value="Retry" />
@@ -63,10 +55,14 @@
<Dialog Id="InstallWelcome" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Install [ProductName]" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="40"
Text="Setup will install [ProductName] for all users and add it to the Start menu. Click Install to continue." />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="40" Text="Setup will install [ProductName] for all users and add it to the Start menu. Click Install to continue." HideCondition="ALLUSERS &lt;&gt; 1" ShowCondition="ALLUSERS = 1" />
<Control Id="DescriptionUser" Type="Text" X="20" Y="55" Width="330" Height="40" Text="Setup will install [ProductName] only for your account and add it to the Start menu. Click Install to continue." HideCondition="ALLUSERS = 1" ShowCondition="ALLUSERS &lt;&gt; 1" />
<Control Id="Install" Type="PushButton" X="195" Y="235" Width="75" Height="23" Default="yes" ElevationShield="yes" Text="&amp;Install">
<Control Id="Install" Type="PushButton" X="195" Y="235" Width="75" Height="23" Default="yes" DefaultCondition="ALLUSERS = 1" ElevationShield="yes" HideCondition="ALLUSERS &lt;&gt; 1" ShowCondition="ALLUSERS = 1" Text="&amp;Install">
<Publish Event="EndDialog" Value="Return" Order="99" />
</Control>
<Control Id="InstallUser" Type="PushButton" DefaultCondition="ALLUSERS &lt;&gt; 1" HideCondition="ALLUSERS = 1" ShowCondition="ALLUSERS &lt;&gt; 1" X="195" Y="235" Width="75" Height="23" Text="&amp;Install">
<Publish Event="EndDialog" Value="Return" Order="99" />
</Control>
@@ -77,16 +73,27 @@
<Dialog Id="InstallMaintenance" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Maintain [ProductName]" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] is already installed. Choose Repair to restore missing files and shortcuts, or Remove to uninstall it." />
<Control Id="Repair" Type="PushButton" X="110" Y="235" Width="75" Height="23" Default="yes" ElevationShield="yes" Text="&amp;Repair">
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70" Text="[ProductName] is already installed. Choose Repair to restore missing files and shortcuts, or Remove to uninstall it." />
<Control Id="Repair" Type="PushButton" X="110" Y="235" Width="75" Height="23" Default="yes" DefaultCondition="ALLUSERS = 1" ElevationShield="yes" HideCondition="ALLUSERS &lt;&gt; 1" ShowCondition="ALLUSERS = 1" Text="&amp;Repair">
<!-- Feature events do not retain REINSTALL/REMOVE in the client UI session. -->
<Publish Property="CraftInstallerOperation" Value="repair" Order="1" />
<Publish Event="ReinstallMode" Value="amus" Order="2" />
<Publish Event="Reinstall" Value="ALL" Order="3" />
<Publish Event="EndDialog" Value="Return" Order="4" />
</Control>
<Control Id="Remove" Type="PushButton" X="195" Y="235" Width="75" Height="23" ElevationShield="yes" Text="R&amp;emove">
<Control Id="RepairUser" Type="PushButton" DefaultCondition="ALLUSERS &lt;&gt; 1" HideCondition="ALLUSERS = 1" ShowCondition="ALLUSERS &lt;&gt; 1" X="110" Y="235" Width="75" Height="23" Text="&amp;Repair">
<!-- Feature events do not retain REINSTALL/REMOVE in the client UI session. -->
<Publish Property="CraftInstallerOperation" Value="repair" Order="1" />
<Publish Event="ReinstallMode" Value="amus" Order="2" />
<Publish Event="Reinstall" Value="ALL" Order="3" />
<Publish Event="EndDialog" Value="Return" Order="4" />
</Control>
<Control Id="Remove" Type="PushButton" X="195" Y="235" Width="75" Height="23" ElevationShield="yes" HideCondition="ALLUSERS &lt;&gt; 1" ShowCondition="ALLUSERS = 1" Text="R&amp;emove">
<Publish Property="CraftInstallerOperation" Value="remove" Order="1" />
<Publish Event="Remove" Value="ALL" Order="2" />
<Publish Event="EndDialog" Value="Return" Order="3" />
</Control>
<Control Id="RemoveUser" Type="PushButton" HideCondition="ALLUSERS = 1" ShowCondition="ALLUSERS &lt;&gt; 1" X="195" Y="235" Width="75" Height="23" Text="R&amp;emove">
<Publish Property="CraftInstallerOperation" Value="remove" Order="1" />
<Publish Event="Remove" Value="ALL" Order="2" />
<Publish Event="EndDialog" Value="Return" Order="3" />
@@ -121,16 +128,10 @@
<Dialog Id="InstallComplete" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Setup completed successfully" HideCondition="REMOVE = &quot;ALL&quot; OR CraftInstallerOperation = &quot;remove&quot;" />
<Control Id="RemovedTitle" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Uninstall completed successfully" Hidden="yes" ShowCondition="REMOVE = &quot;ALL&quot; OR CraftInstallerOperation = &quot;remove&quot;" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] is installed. Open it from the Start menu. Click Finish to close setup." HideCondition="REMOVE = &quot;ALL&quot; OR REINSTALL OR CraftInstallerOperation OR ACTION = &quot;ADMIN&quot;" />
<Control Id="Removed" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes"
Text="[ProductName] was uninstalled. Your documents and settings were kept. Click Finish to close setup."
ShowCondition="REMOVE = &quot;ALL&quot; OR CraftInstallerOperation = &quot;remove&quot;" />
<Control Id="Repaired" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes"
Text="[ProductName] was repaired. Open it from the Start menu. Click Finish to close setup."
ShowCondition="(REINSTALL OR CraftInstallerOperation = &quot;repair&quot;) AND NOT REMOVE AND CraftInstallerOperation &lt;&gt; &quot;remove&quot; AND ACTION &lt;&gt; &quot;ADMIN&quot;" />
<Control Id="AdminComplete" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes"
Text="The administrative image was created. Click Finish to close setup." ShowCondition="ACTION = &quot;ADMIN&quot;" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70" Text="[ProductName] is installed. Open it from the Start menu. Click Finish to close setup." HideCondition="REMOVE = &quot;ALL&quot; OR REINSTALL OR CraftInstallerOperation OR ACTION = &quot;ADMIN&quot;" />
<Control Id="Removed" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes" Text="[ProductName] was uninstalled. Your documents and settings were kept. Click Finish to close setup." ShowCondition="REMOVE = &quot;ALL&quot; OR CraftInstallerOperation = &quot;remove&quot;" />
<Control Id="Repaired" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes" Text="[ProductName] was repaired. Open it from the Start menu. Click Finish to close setup." ShowCondition="(REINSTALL OR CraftInstallerOperation = &quot;repair&quot;) AND NOT REMOVE AND CraftInstallerOperation &lt;&gt; &quot;remove&quot; AND ACTION &lt;&gt; &quot;ADMIN&quot;" />
<Control Id="AdminComplete" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes" Text="The administrative image was created. Click Finish to close setup." ShowCondition="ACTION = &quot;ADMIN&quot;" />
<Control Id="Finish" Type="PushButton" X="280" Y="235" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">
<Publish Event="EndDialog" Value="Return" Order="999" />
@@ -140,8 +141,7 @@
<Dialog Id="InstallFailed" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Setup failed" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] setup could not be completed. Resolve the reported error and run setup again." />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70" Text="[ProductName] setup could not be completed. Resolve the reported error and run setup again." />
<Control Id="Finish" Type="PushButton" X="280" Y="235" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">
<Publish Event="EndDialog" Value="Return" />
</Control>
@@ -149,8 +149,7 @@
<Dialog Id="InstallCancelled" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Setup cancelled" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] setup was cancelled. Run setup again when you are ready." />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70" Text="[ProductName] setup was cancelled. Run setup again when you are ready." />
<Control Id="Finish" Type="PushButton" X="280" Y="235" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">
<Publish Event="EndDialog" Value="Return" />
</Control>
@@ -161,8 +160,7 @@
continues; Cancel ends setup. -->
<Dialog Id="MsiRMFilesInUse" Width="370" Height="200" Title="[ProductName] Setup" KeepModeless="yes">
<Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="25" Text="{\CraftInstallerTitle}Files in use" />
<Control Id="Description" Type="Text" X="20" Y="45" Width="330" Height="30"
Text="These programs are using files that setup needs to update. Click OK to close them and continue, or Cancel to stop setup." />
<Control Id="Description" Type="Text" X="20" Y="45" Width="330" Height="30" Text="These programs are using files that setup needs to update. Click OK to close them and continue, or Cancel to stop setup." />
<Control Id="List" Type="ListBox" X="20" Y="80" Width="330" Height="55" Property="FileInUseProcess" Sunken="yes" TabSkip="yes" />
<Control Id="OK" Type="PushButton" X="195" Y="165" Width="75" Height="23" Default="yes" Text="OK">
<Publish Property="MSIRMSHUTDOWN" Value="2" Order="1" />
@@ -197,12 +195,12 @@
<Property Id="ARPSYSTEMCOMPONENT" Value="1" />
<?if $(sys.BUILDARCH) = x86 ?>
<?define CraftMsiSystemFolder = SystemFolder ?>
<?else ?>
<?else?>
<?define CraftMsiSystemFolder = System64Folder ?>
<?endif ?>
<?endif?>
<ComponentGroup Id="CraftInstallerSettings" Directory="INSTALLFOLDER">
<Component Id="CraftInstallerSettingsEntry" Guid="*">
<RegistryKey Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.designcraft" ForceDeleteOnUninstall="yes">
<RegistryKey Root="HKMU" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.designcraft" ForceDeleteOnUninstall="yes">
<!-- Legacy Launchers match the canonical MSI name exactly; keep this alias distinct. -->
<RegistryValue Name="DisplayName" Type="string" Value="[ProductName] (Craft Apps)" KeyPath="yes" />
<RegistryValue Name="DisplayVersion" Type="string" Value="[ProductVersion]" />
@@ -220,8 +218,7 @@
</Component>
<!-- Preserve the app-owned ARPNOMODIFY policy instead of inventing a new default. -->
<Component Id="CraftInstallerSettingsNoModify" Guid="*" Condition="ARPNOMODIFY = &quot;1&quot;">
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.designcraft"
Name="NoModify" Type="integer" Value="1" KeyPath="yes" />
<RegistryValue Root="HKMU" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.designcraft" Name="NoModify" Type="integer" Value="1" KeyPath="yes" />
</Component>
</ComponentGroup>
</Fragment>
+2
View File
@@ -114,6 +114,8 @@ Invoke-Native 'wix build' {
-d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" `
-o $Msi
}
# Check the compiled scope, path and registration contract before signing.
& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
+54
View File
@@ -0,0 +1,54 @@
<#
.SYNOPSIS
Build non-executable installer metadata fixtures; never install them.
#>
param(
[Parameter(Mandatory = $true)] [string] $OutputDirectory,
[ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64')
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json
[void](New-Item -ItemType Directory -Force -Path $OutputDirectory)
$output = (Resolve-Path -LiteralPath $OutputDirectory).Path
Add-Type -AssemblyName System.Drawing
foreach ($arch in $Architectures) {
$stage = Join-Path $output $arch
[void](New-Item -ItemType Directory -Force -Path $stage)
foreach ($name in $policy.payloads) {
$destination = Join-Path $stage $name
[void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination)))
Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE'
}
foreach ($folder in @('docs','models','notices')) {
$path = Join-Path $stage $folder
[void](New-Item -ItemType Directory -Force -Path $path)
Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.'
}
foreach ($model in @('metadata-one.rten','metadata-two.rten')) {
Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE'
}
foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) {
$bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb)
try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) }
finally { $bitmap.Dispose() }
}
Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}'
$sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ })
$msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi"
$extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" })
& wix build @sources -arch $arch @extensions `
-culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' `
-d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" `
-d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" `
-d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' `
-d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" `
-d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" `
-d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ })
if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." }
$shell = (Get-Process -Id $PID).Path
& $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi
if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." }
Write-Output "PASS $arch metadata-only package. This is not an executable installer."
}
+1 -1
View File
@@ -5,7 +5,7 @@ use std::process::Command;
use serde_json::Value;
const REPO: &str = "https://github.com/storytold/craft-libs";
const REV: &str = "b926b5c03e256c86ca10934f58d03f628810b5b7";
const REV: &str = "92d392cff132eaf53e13afff77f38b5c11b73aa0";
const PACKAGES: &[(&str, &str)] = &[
("craft-pdf", "0.1.0"),
("krilla", "0.8.2"),