From 0826101c5edae7da9db52cf504b2406ed6122ca2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:17:38 +0300 Subject: [PATCH] Blocks palette: tabs, options, drag insertion and -INSERT Rebuild the Blocks palette with Current Drawing / Recent / Favorites / Libraries tabs, wildcard filter, six views, insertion options, click and drop insertion and a block context menu. INSERT opens the palette, the new -INSERT runs on the command line, the ribbon Insert button becomes a block gallery, and BLOCKMRULIST, BLOCKREDEFINEMODE, BLOCKNAVIGATE, BLOCKSTATE, EXPLMODE and INSNAME are added. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 34 + locales/bg-BG/opencadstudio.ftl | 34 + locales/cs-CZ/opencadstudio.ftl | 34 + locales/de-DE/opencadstudio.ftl | 34 + locales/el-GR/opencadstudio.ftl | 34 + locales/en-US/opencadstudio.ftl | 34 + locales/es-ES/opencadstudio.ftl | 34 + locales/fi-FI/opencadstudio.ftl | 34 + locales/fr-FR/opencadstudio.ftl | 34 + locales/hi-IN/opencadstudio.ftl | 34 + locales/hu-HU/opencadstudio.ftl | 34 + locales/it-IT/opencadstudio.ftl | 34 + locales/ja-JP/opencadstudio.ftl | 34 + locales/ko-KR/opencadstudio.ftl | 34 + locales/nl-NL/opencadstudio.ftl | 34 + locales/pl-PL/opencadstudio.ftl | 34 + locales/pt-BR/opencadstudio.ftl | 34 + locales/ru-RU/opencadstudio.ftl | 34 + locales/tr-TR/opencadstudio.ftl | 34 + locales/zh-CN/opencadstudio.ftl | 34 + locales/zh-TW/opencadstudio.ftl | 34 + src/app/command_driver/entity_commit.rs | 23 +- src/app/command_driver/mod.rs | 26 + src/app/commands/blocks.rs | 89 ++- src/app/commands/blockvars.rs | 125 ++++ src/app/commands/dim.rs | 21 + src/app/commands/mod.rs | 12 + src/app/commands/styleprops.rs | 1 + src/app/control/actions.rs | 88 +++ src/app/control/entities.rs | 2 +- src/app/control/mod.rs | 1 + src/app/mod.rs | 12 + src/app/settings.rs | 98 +++ src/app/update/blocks_palette.rs | 603 +++++++++++++++ src/app/update/dialog.rs | 160 ++-- src/app/update/file.rs | 19 + src/app/update/mod.rs | 4 + src/app/update/viewport.rs | 5 + src/app/view/mod.rs | 15 +- src/command.rs | 12 + src/locale_catalog.rs | 34 + src/modules/draw/mod.rs | 2 +- src/modules/insert/insert_block.rs | 902 +++++++++++++++++------ src/modules/insert/mod.rs | 2 +- src/scene/entity.rs | 40 + src/ui/ribbon/mod.rs | 15 +- src/ui/ribbon/widgets.rs | 4 +- src/ui/window/block_palette.rs | 939 ++++++++++++++++++------ src/ui/window/pdf_dialogs.rs | 6 +- 49 files changed, 3401 insertions(+), 573 deletions(-) create mode 100644 src/app/commands/blockvars.rs create mode 100644 src/app/update/blocks_palette.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 49b2b7ec2..b11b307a2 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4032,6 +4032,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 11eec1078..c52fbc5d0 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ecef5c891..a9b707039 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4017,6 +4017,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 9f6184f50..9d6a85a0c 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4018,6 +4018,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index c4fa2d108..af55a9273 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index f958e0a44..8d0d15a28 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4021,6 +4021,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 9adc06b83..e9aeb37f4 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 20dd5dfa0..653040ffb 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 01c1d1bb6..e65125269 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index ed27d6574..5f35ca1ae 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -4008,6 +4008,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index bc78e77d9..b3fca5218 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 835f99590..f933595f0 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 969801da3..afba393bd 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -4008,6 +4008,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 8cce74f8e..5fcd3d4e1 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index a1f00441a..495fd45f3 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4018,6 +4018,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index f48cd9afb..87db5e698 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4019,6 +4019,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index ca46a1361..fc8d3184d 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4020,6 +4020,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index dc296c1f3..af45f94bc 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4021,6 +4021,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 0c73a6649..e3d231679 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3980,6 +3980,40 @@ insert = .pcs-positive = Pozitif bir sayı gerekir. .pcs-positive-int = Pozitif bir tamsayı gerekir. .pcs-help = Canlı kesitin korunan tarafındaki noktaları çizgilerle izler. + .bp-add-favorite = Sık kullanılanlara ekle + .bp-annotative-dynamic = Açıklayıcı / Dinamik + .bp-auto-placement = Otomatik yerleştirme + .bp-gallery-libraries = Kütüphanelerden bloklar... + .bp-choose-library = … ile bir kütüphane klasörü seçin + .bp-tab-current = Geçerli Çizim + .bp-heading-current = Geçerli çizimdeki bloklar + .bp-view-xl = Çok büyük simgeler + .bp-heading-favorites = Sık kullanılan bloklar + .bp-gallery-favorites = Sık kullanılan bloklar... + .bp-tab-favorites = Sık Kullanılan + .bp-filter = Filtre... (* ? joker) + .bp-icon = Simge + .bp-insertion-point = Ekleme noktası + .bp-view-large = Büyük simgeler + .bp-tab-libraries = Kütüphaneler + .bp-heading-libraries = Kütüphane blokları + .bp-view-medium = Orta simgeler + .bp-tab-recent = Son Kullanılan + .bp-heading-recent = Son kullanılan bloklar + .bp-gallery-recent = Son kullanılan bloklar... + .bp-redefine = Yeniden tanımla + .bp-remove-block = Bloğu kaldır + .bp-remove-favorite = Sık kullanılanlardan çıkar + .bp-remove-list = Listeden kaldır + .bp-repeat-placement = Yerleştirmeyi yinele + .bp-select-library = Blok kütüphanesi klasörü seçin + .bp-view-small = Küçük simgeler + .bp-on-screen = ekranda belirtilir + .bp-block-not-found = "__ocs_fmt_0__" bloğu bulunamadı. + .bp-block-redefined = "__ocs_fmt_0__" bloğu yeniden tanımlandı. + .bp-block-no-reference = "__ocs_fmt_0__" bloğunun çizimde düzenlenecek bir başvurusu yok. + .bp-block-referenced = "__ocs_fmt_0__" bloğu çizimde kullanılıyor, kaldırılamaz. + .bp-block-removed = "__ocs_fmt_0__" bloğu kaldırıldı. model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index be1fdc04e..9c7572400 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4003,6 +4003,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 38707859d..ff26b0b01 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4015,6 +4015,40 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .bp-add-favorite = Add to Favorites + .bp-annotative-dynamic = Annotative / Dynamic + .bp-auto-placement = Auto-Placement + .bp-gallery-libraries = Blocks from Libraries... + .bp-choose-library = Choose a library folder with … + .bp-tab-current = Current Drawing + .bp-heading-current = Current Drawing Blocks + .bp-view-xl = Extra Large Icons + .bp-heading-favorites = Favorite Blocks + .bp-gallery-favorites = Favorite Blocks... + .bp-tab-favorites = Favorites + .bp-filter = Filter... (* ? wildcards) + .bp-icon = Icon + .bp-insertion-point = Insertion Point + .bp-view-large = Large Icons + .bp-tab-libraries = Libraries + .bp-heading-libraries = Library Blocks + .bp-view-medium = Medium Icons + .bp-tab-recent = Recent + .bp-heading-recent = Recent Blocks + .bp-gallery-recent = Recent Blocks... + .bp-redefine = Redefine + .bp-remove-block = Remove Block + .bp-remove-favorite = Remove from Favorites + .bp-remove-list = Remove from List + .bp-repeat-placement = Repeat Placement + .bp-select-library = Select a Block Library Folder + .bp-view-small = Small Icons + .bp-on-screen = specified on screen + .bp-block-not-found = Block "{}" not found. + .bp-block-redefined = Block "{name}" redefined. + .bp-block-no-reference = Block "{name}" has no reference in the drawing to edit. + .bp-block-referenced = Block "{name}" is referenced in the drawing and cannot be removed. + .bp-block-removed = Block "{name}" removed. model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 2fcb4e256..5585e04d6 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -748,6 +748,7 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("Block \"{name}\" created.").as_ref()); self.refresh_properties(); + self.note_recent_block(&name); self.refresh_block_palette(); } Err(err) => { @@ -985,7 +986,16 @@ impl OpenCADStudio { self.commit_entity(entity); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; + // A repeating insertion keeps its command for the next block. + let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue()); + if keep { + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } else { + self.tabs[i].active_cmd = None; + } self.tabs[i].snap_result = None; if let Some(n) = insert_name { self.record_block_insert(&n); @@ -1001,7 +1011,16 @@ impl OpenCADStudio { .as_mut() .and_then(|cmd| cmd.attreq_set_attdefs(attdefs)); if let Some(entity) = completed { - return Some(self.apply_cmd_result(CmdResult::CommitAndExit(entity))); + if let codec::EntityType::Insert(ins) = &entity { + let name = ins.block_name.clone(); + self.record_block_insert(&name); + } + let keep = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.attreq_continue()); + return Some(self.apply_cmd_result(if keep { + CmdResult::CommitEntity(entity) + } else { + CmdResult::CommitAndExit(entity) + })); } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 34cbd7f4f..76ba3bfb0 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -767,6 +767,32 @@ impl OpenCADStudio { return task; } } + CmdResult::CommitExplodedInsert { insert, keep_going } => { + let i = self.active_tab; + if let codec::EntityType::Insert(ins) = &insert { + let name = ins.block_name.clone(); + self.record_block_insert(&name); + } + let pieces = crate::modules::draw::modify::explode::explode_entity( + &insert, + &self.tabs[i].scene.document, + ); + if keep_going { + if let Some(task) = self.handle_commit_entities( + pieces, + preserve_commit_style, + preserve_commit_layer, + ) { + return task; + } + } else { + self.handle_commit_entities_and_exit( + pieces, + preserve_commit_style, + preserve_commit_layer, + ); + } + } CmdResult::AttreqNeeded { block_name } => { if let Some(task) = self.handle_attreq_needed(block_name) { return task; diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 7f671436b..9f9cc1aea 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -489,25 +489,50 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::WriteBlock); } + // INSERT opens the Blocks palette on the drawing's blocks; + // -INSERT runs on the command line. "INSERT" => { + self.command_line.push_output("*Insert a block from the Blocks palette"); + self.open_blocks_palette(Some(crate::ui::window::block_palette::Tab::Current)); + } + + "-INSERT" => { + use crate::command::CadCommand; + use crate::modules::insert::insert_block::InsertBlockCommand; let blocks = self.tabs[i].scene.custom_block_names(); - if blocks.is_empty() { - self.command_line.push_error( - crate::t!("No user-defined blocks found in this drawing.").as_ref(), - ); - } else { - use crate::modules::insert::insert_block::InsertBlockCommand; - let ranked = self.ranked_block_names(&blocks); - let snapshot = self.block_usage_snapshot(); - let cmd = InsertBlockCommand::new_with_usage( - ranked, - snapshot, - self.cliprompt_lines.clamp(0, 50) as u8, - ); - self.command_line.push_info(&cmd.prompt()); - let opts = cmd.options(); - self.command_line.set_step_options(opts.clone()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = InsertBlockCommand::classic( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + self.block_catalog(), + self.insname.clone(), + ); + self.command_line.push_info(&cmd.prompt()); + self.command_line.set_step_options(cmd.options()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + + // A drawing named at -INSERT's block prompt: brought in as a + // block, then placed. + cmd if cmd.starts_with("_-INSERTFILE ") => { + use crate::command::CadCommand; + use crate::modules::insert::insert_block::InsertBlockCommand; + let path = std::path::PathBuf::from(cmd["_-INSERTFILE ".len()..].trim().trim_matches('"')); + match self.import_file_as_block(path) { + Ok(name) => { + let units = self + .block_catalog() + .units + .remove(&name.to_ascii_uppercase()) + .unwrap_or_default(); + let preview = self.tabs[i].scene.block_preview_wires(&name); + let cmd = InsertBlockCommand::classic_for_block(name, preview, units); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + Err(e) => self.command_line.push_error(e.as_str()), } } @@ -667,15 +692,27 @@ impl OpenCADStudio { ); } - // BLOCKPALETTE / BLOCKSPALETTE — toggle the docked Insert Block panel. - "BLOCKPALETTE" | "BLOCKSPALETTE" => { - self.show_block_palette ^= true; - if self.show_block_palette { - // Always open expanded so the panel is immediately usable; - // the user can still collapse it via the pin (Auto) button. - self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette); - self.refresh_block_palette(); - } + // BLOCKSPALETTE opens the Blocks palette; BLOCKSPALETTECLOSE + // closes it. `_BLOCKSPALETTE ` opens it on a tab. + "BLOCKPALETTE" | "BLOCKSPALETTE" => self.open_blocks_palette(None), + "BLOCKSPALETTECLOSE" => { + self.show_block_palette = false; + self.block_palette.placing = None; + } + // A block picked in the ribbon gallery. + cmd if cmd.starts_with("_BLOCKINSERT ") => { + let name = cmd["_BLOCKINSERT ".len()..].trim().to_string(); + return Some(self.palette_insert(crate::ui::window::block_palette::Item::Current(name), None)); + } + cmd if cmd.starts_with("_BLOCKSPALETTE ") => { + use crate::ui::window::block_palette::Tab; + let tab = match cmd["_BLOCKSPALETTE ".len()..].trim() { + "RECENT" => Tab::Recent, + "FAVORITES" => Tab::Favorites, + "LIBRARIES" => Tab::Libraries, + _ => Tab::Current, + }; + self.open_blocks_palette(Some(tab)); } // ATTMAN / BATTMAN — the Block Attribute Manager. Rather than a diff --git a/src/app/commands/blockvars.rs b/src/app/commands/blockvars.rs new file mode 100644 index 000000000..fae4f4eae --- /dev/null +++ b/src/app/commands/blockvars.rs @@ -0,0 +1,125 @@ +//! Block system variables. `BLOCKMRULIST`, `BLOCKREDEFINEMODE` and +//! `BLOCKNAVIGATE` are application preferences kept with the settings; +//! `EXPLMODE` lives in the drawing; `INSNAME` is the session's default block +//! name; `BLOCKSTATE` is read-only. Each answers to its bare name (prompting +//! for the value) and to `SETVAR NAME value`, with the reference's messages. + +use crate::app::{Message, OpenCADStudio}; +use iced::Task; + +/// The variables this family answers for, for the command registry and the +/// `SETVAR ?` listing. +pub(super) const BLOCK_SYSVARS: &[&str] = &[ + "BLOCKMRULIST", + "BLOCKREDEFINEMODE", + "BLOCKNAVIGATE", + "BLOCKSTATE", + "EXPLMODE", + "INSNAME", +]; + +/// The `SETVAR ?` line for this family. +pub(super) fn setvar_listing() -> String { + format!("SETVAR (block): {}", BLOCK_SYSVARS.join(" ")) +} + +/// EXPLMODE of the drawing (default 1). +pub(crate) fn explmode(document: &codec::CadDocument) -> i16 { + crate::io::drawing_variable(document, "EXPLMODE") + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(1) +} + +impl OpenCADStudio { + pub(super) fn dispatch_blockvars(&mut self, cmd: &str, i: usize) -> Option> { + let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd); + let mut parts = rest.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or("").to_ascii_uppercase(); + if !BLOCK_SYSVARS.contains(&name.as_str()) { + return None; + } + let value = parts.next().map(str::trim).filter(|v| !v.is_empty()); + match name.as_str() { + "BLOCKSTATE" => { + let state = self.tabs[i].refedit_session.is_some() as u8; + self.command_line.push_output(&format!("BLOCKSTATE = {state} (read only)")); + } + "INSNAME" | "BLOCKNAVIGATE" => { + let current = if name == "INSNAME" { + self.insname.clone() + } else { + self.block_navigate.clone() + }; + match value { + Some(v) => { + let v = if v == "." { String::new() } else { v.trim_matches('"').to_string() }; + if name == "INSNAME" { + self.insname = v; + } else { + self.block_navigate = if v.is_empty() { ".".to_string() } else { v }; + self.persist_settings_if_changed(); + } + } + None => { + self.command_line.push_output(&format!( + "Enter new value for {name}, or . for none <\"{current}\">:" + )); + self.pending_setvar = Some(name); + } + } + } + _ => { + let (current, max) = match name.as_str() { + "BLOCKMRULIST" => (self.block_mru_list as i32, 100), + "BLOCKREDEFINEMODE" => (self.block_redefine_mode as i32, 2), + _ => (explmode(&self.tabs[i].scene.document) as i32, 1), + }; + let ask = |app: &mut Self, name: String| { + app.command_line + .push_output(&format!("Enter new value for {name} <{current}>:")); + app.pending_setvar = Some(name); + }; + match value { + None => ask(self, name), + Some(v) => match v.parse::() { + Err(_) => { + self.command_line.push_error("Requires an integer value."); + ask(self, name); + } + Ok(n) if !(0..=max).contains(&n) => { + self.command_line.push_error(&if max == 1 { + "Requires 0 or 1 only.".to_string() + } else { + format!("Requires an integer between 0 and {max}.") + }); + ask(self, name); + } + Ok(n) => match name.as_str() { + "BLOCKMRULIST" => { + self.block_mru_list = n as u8; + self.trim_recent_blocks(); + self.persist_settings_if_changed(); + } + "BLOCKREDEFINEMODE" => { + self.block_redefine_mode = n as u8; + self.persist_settings_if_changed(); + } + _ => { + if n != current { + self.push_undo_snapshot(i, "EXPLMODE"); + crate::io::set_drawing_variable( + &mut self.tabs[i].scene.document, + "EXPLMODE", + &n.to_string(), + ); + self.tabs[i].dirty = true; + } + } + }, + }, + } + } + } + Some(Task::none()) + } +} diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 770007d25..2bc34742a 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1217,6 +1217,27 @@ impl OpenCADStudio { self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { + // EXPLMODE 0 keeps references that are not uniformly and + // positively scaled whole. + let selected: Vec<_> = if super::blockvars::explmode(&self.tabs[i].scene.document) == 0 { + selected + .into_iter() + .filter(|(_, entity)| match entity { + codec::EntityType::Insert(ins) => { + let (x, y, z) = (ins.x_scale(), ins.y_scale(), ins.z_scale()); + let uniform = x > 0.0 && (x - y).abs() <= 1e-10 && (x - z).abs() <= 1e-10; + if !uniform { + self.command_line + .push_output("The object is not uniformly and positively scaled."); + } + uniform + } + _ => true, + }) + .collect() + } else { + selected + }; let replacements = plan_explode(&selected, &self.tabs[i].scene.document); let exploded = replacements.len(); if exploded > 0 { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index c39a58a6a..3c8b19754 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -20,6 +20,7 @@ mod pc_extract; mod pdf_underlay; mod pdf_dialogs; mod xclip; +mod blockvars; mod plotvars; mod styleprops; mod view; @@ -296,6 +297,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_plotvars(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_blockvars(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_styleprops(cmd, i) { return Some(t); } @@ -473,6 +477,14 @@ inventory::submit!(crate::command::CommandRegistration { // Block list + block-attribute list (command-line forms). "BLOCKPALETTE", "BLOCKSPALETTE", + "BLOCKSPALETTECLOSE", + "-INSERT", + "BLOCKMRULIST", + "BLOCKREDEFINEMODE", + "BLOCKNAVIGATE", + "BLOCKSTATE", + "EXPLMODE", + "INSNAME", "ATTMAN", "BATTMAN", // Drawing-content overview. diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 8479f551a..2040e8f10 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1107,6 +1107,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info(&super::plotvars::setvar_listing()); + self.command_line.push_info(&super::blockvars::setvar_listing()); self.command_line.push_info( crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 274f1f1da..35566cd2f 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -73,6 +73,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_layer_toggle", "pc_manager_toggle", "pc_manager", + "blocks_palette", "pdf_page_select", "pc_colormap", "pc_section", @@ -768,6 +769,93 @@ impl OpenCADStudio { _ => return Err(failure("invalid_value", "Unknown point cloud manager edit")), }) } + // Blocks palette: "tab=recent", "search=D*", "view=2", + // "option=x:2", "options", "library=", "insert=", + // "drop=@x,y", "menu=:". An item is + // "current:NAME", "recent:N", "fav:N", "lib:N" or "file:". + "blocks_palette" => { + use crate::ui::window::block_palette::{BlockPaletteMsg as M, Item, MenuAction, OptionMsg, Tab, ViewMode}; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + let item = |app: &Self, spec: &str| -> Result { + let (kind, rest) = spec.split_once(':').unwrap_or((spec, "")); + let index = || rest.parse::().map_err(|_| failure("invalid_value", "Bad item index")); + let missing = || failure("invalid_value", "No such palette item"); + Ok(match kind { + "current" => Item::Current(rest.to_string()), + "recent" => Item::Ref(app.block_palette.recent.get(index()?).cloned().ok_or_else(missing)?), + "fav" => Item::Ref(app.block_palette.favorites.get(index()?).cloned().ok_or_else(missing)?), + "lib" => { + let e = app.block_palette.library_entries.get(index()?).ok_or_else(missing)?; + if e.folder { Item::Folder(e.path.clone()) } else { Item::File(e.path.clone()) } + } + "file" => Item::File(std::path::PathBuf::from(rest)), + _ => return Err(missing()), + }) + }; + match key { + "tab" => Message::BlockPalette(M::Tab(match arg { + "recent" => Tab::Recent, + "favorites" => Tab::Favorites, + "libraries" => Tab::Libraries, + _ => Tab::Current, + })), + "search" => Message::BlockPalette(M::Search(arg.into())), + "view" => Message::BlockPalette(M::View(ViewMode::from_u8(arg.parse().unwrap_or(1)))), + "options" => Message::BlockPalette(M::ToggleOptions), + "library" => Message::BlockPalette(M::LibraryPicked(Ok(arg.into()))), + "up" => Message::BlockPalette(M::LibraryUp), + "option" => { + let (name, v) = arg.split_once(':').unwrap_or((arg, "1")); + let on = v == "1"; + Message::BlockPalette(M::Option(match name { + "insertion_point" => OptionMsg::InsertionPoint(on), + "scale" => OptionMsg::Scale(on), + "uniform" => OptionMsg::Uniform(on), + "rotation" => OptionMsg::Rotation(on), + "auto" => OptionMsg::AutoPlacement(on), + "repeat" => OptionMsg::Repeat(on), + "explode" => OptionMsg::Explode(on), + "x" => OptionMsg::X(v.into()), + "y" => OptionMsg::Y(v.into()), + "z" => OptionMsg::Z(v.into()), + "angle" => OptionMsg::Angle(v.into()), + _ => return Err(failure("invalid_value", "Unknown palette option")), + })) + } + "insert" => { + let it = item(self, arg)?; + self.block_palette.pressed = Some(it.clone()); + Message::BlockPalette(M::Release(it)) + } + "drop" => { + let (spec, at) = arg.rsplit_once('@').ok_or_else(|| failure("invalid_value", "drop needs @x,y"))?; + let xy: Vec = at.split(',').filter_map(|v| v.trim().parse().ok()).collect(); + if xy.len() < 2 { + return Err(failure("invalid_value", "drop needs @x,y")); + } + let it = item(self, spec)?; + let i = self.active_tab; + self.tabs[i].last_cursor_world = glam::DVec3::new(xy[0], xy[1], xy.get(2).copied().unwrap_or(0.0)); + self.block_palette.pressed = Some(it); + Message::ViewportLeftRelease + } + "menu" => { + let (action, spec) = arg.split_once(':').ok_or_else(|| failure("invalid_value", "menu needs action:item"))?; + let action = match action { + "insert" => MenuAction::Insert, + "redefine" => MenuAction::Redefine, + "favorite" => MenuAction::Favorite, + "unfavorite" => MenuAction::Unfavorite, + "edit" => MenuAction::Edit, + "remove" => MenuAction::Remove, + _ => return Err(failure("invalid_value", "Unknown menu action")), + }; + Message::BlockPalette(M::Menu(item(self, spec)?, action)) + } + _ => return Err(failure("invalid_value", "Unknown blocks palette edit")), + } + } // Attach dialog: choose pages by index ("0,2"). "pdf_page_select" => { let pages: Vec = string(req, "value")? diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs index 7eb5acd64..c4da959ac 100644 --- a/src/app/control/entities.rs +++ b/src/app/control/entities.rs @@ -676,7 +676,7 @@ impl OpenCADStudio { /// Silent no-op for an unknown name (callers validate first); /// `*`-prefixed layout records are rejected by the callers. Returns the /// number of entities erased. -fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize { +pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize { let Some(record) = scene.document.block_records.get(name) else { return 0; }; diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e2e64735a..5ed5c5657 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1340,6 +1340,7 @@ impl OpenCADStudio { mod actions; pub(crate) use actions::property_json; mod entities; +pub(crate) use entities::erase_block_definition; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod http_bridge; mod interactive; diff --git a/src/app/mod.rs b/src/app/mod.rs index 65df40ec0..01f2f6412 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -671,6 +671,14 @@ pub(super) struct OpenCADStudio { block_mru: Vec, /// Insertion frequency per block name (uppercase key → count), capped. block_freq: std::collections::HashMap, + /// BLOCKMRULIST: how many recent blocks the palette keeps (0–100). + pub(crate) block_mru_list: u8, + /// BLOCKREDEFINEMODE (0–2). + pub(crate) block_redefine_mode: u8, + /// BLOCKNAVIGATE: the Libraries tab's start folder ("." for none). + pub(crate) block_navigate: String, + /// INSNAME: the default block name for -INSERT (session only). + pub(crate) insname: String, /// Last time block-usage was flushed to disk (debounce per 2.4). #[cfg(not(target_arch = "wasm32"))] block_usage_last_persist: Option, @@ -4142,6 +4150,10 @@ impl OpenCADStudio { commandline_fade_ms: 3000, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), + block_mru_list: 50, + block_redefine_mode: 1, + block_navigate: ".".to_string(), + insname: String::new(), #[cfg(not(target_arch = "wasm32"))] block_usage_last_persist: None, awaiting_vports: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index f4a62191c..c85f736bd 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -546,6 +546,96 @@ pub struct UserSettings { /// Insertion frequency per block name (uppercase key → count). #[serde(default)] pub block_freq: std::collections::HashMap, + /// Blocks palette: the recent list (newest first, BLOCKMRULIST long). + #[serde(default)] + pub block_recent: Vec, + /// Blocks palette: favorites, kept locally. + #[serde(default)] + pub block_favorites: Vec, + /// Blocks palette: library folders or drawings, last used first. + #[serde(default)] + pub block_libraries: Vec, + /// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons, + /// 4 details, 5 list. + #[serde(default = "default_block_view")] + pub block_palette_view: u8, + /// Blocks palette insertion options. + #[serde(default)] + pub block_insert: BlockInsertOptions, + /// BLOCKMRULIST (0–100, default 50). + #[serde(default = "default_block_mru_list")] + pub block_mru_list: u8, + /// BLOCKREDEFINEMODE (0–2, default 1). + #[serde(default = "default_one_u8")] + pub block_redefine_mode: u8, + /// BLOCKNAVIGATE (default "."). + #[serde(default = "default_block_navigate")] + pub block_navigate: String, +} + +/// A block the Blocks palette lists from outside the current drawing (a +/// recent or favorite entry): its name, the drawing it came from (empty for +/// an unsaved drawing), when it was last used (Unix seconds), and whether it +/// stands for a whole drawing inserted as a block. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PaletteBlockRef { + pub name: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub time: u64, + #[serde(default)] + pub drawing: bool, +} + +/// The Blocks palette's insertion options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct BlockInsertOptions { + pub insertion_point: bool, + pub scale: bool, + pub uniform: bool, + pub x: f64, + pub y: f64, + pub z: f64, + pub rotation: bool, + pub angle: f64, + pub auto_placement: bool, + pub repeat: bool, + pub explode: bool, +} + +impl Default for BlockInsertOptions { + fn default() -> Self { + Self { + insertion_point: true, + scale: false, + uniform: false, + x: 1.0, + y: 1.0, + z: 1.0, + rotation: false, + angle: 0.0, + auto_placement: true, + repeat: false, + explode: false, + } + } +} + +fn default_block_view() -> u8 { + 1 +} + +fn default_block_mru_list() -> u8 { + 50 +} + +fn default_one_u8() -> u8 { + 1 +} + +fn default_block_navigate() -> String { + ".".to_string() } fn default_clipromptlines() -> i32 { @@ -710,6 +800,14 @@ impl Default for UserSettings { grid_beyond_limits: true, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), + block_recent: Vec::new(), + block_favorites: Vec::new(), + block_libraries: Vec::new(), + block_palette_view: 1, + block_insert: BlockInsertOptions::default(), + block_mru_list: 50, + block_redefine_mode: 1, + block_navigate: ".".to_string(), } } } diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs new file mode 100644 index 000000000..8c88eb728 --- /dev/null +++ b/src/app/update/blocks_palette.rs @@ -0,0 +1,603 @@ +//! The Blocks palette on the app side: tabs, the recent / favorite lists, +//! library folders, the insertion options, click and drag insertion, and the +//! block context menu. + +use std::path::{Path, PathBuf}; + +use crate::app::settings::PaletteBlockRef; +use crate::app::{Message, OpenCADStudio}; +use crate::modules::insert::insert_block::{BlockCatalog, InsertBlockCommand}; +use crate::ui::window::block_palette::{ + BlockEntry, BlockPaletteMsg, Item, LibraryEntry, MenuAction, OptionMsg, Tab, +}; +use iced::Task; + +/// Library folders remembered in the Libraries list. +const MAX_LIBRARIES: usize = 10; + +fn now_secs() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + #[cfg(target_arch = "wasm32")] + { + 0 + } +} + +fn file_stem(path: &Path) -> String { + path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Block".to_string()) +} + +fn is_drawing(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("dwg") || e.eq_ignore_ascii_case("dxf")) +} + +impl OpenCADStudio { + pub(super) fn on_block_palette(&mut self, m: BlockPaletteMsg) -> Task { + match m { + BlockPaletteMsg::Search(s) => self.block_palette.search = s, + BlockPaletteMsg::Tab(tab) => self.show_block_palette_tab(tab), + BlockPaletteMsg::View(v) => { + self.block_palette.view = v; + self.persist_settings_if_changed(); + } + BlockPaletteMsg::Refresh => self.refresh_block_palette(), + BlockPaletteMsg::PickFile => { + return Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) + .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)), + ); + } + BlockPaletteMsg::FilePicked(Ok(path)) => return self.palette_insert(Item::File(path), None), + BlockPaletteMsg::FilePicked(Err(e)) | BlockPaletteMsg::LibraryPicked(Err(e)) => { + if e != "Cancelled" { + self.command_line.push_error(&e); + } + } + BlockPaletteMsg::Option(o) => self.on_block_option(o), + BlockPaletteMsg::ToggleOptions => { + self.block_palette.options_collapsed ^= true; + } + BlockPaletteMsg::LibraryBrowse => { + return Task::perform( + async { + match rfd::AsyncFileDialog::new() + .set_title(crate::t!("Select a Block Library Folder").as_ref()) + .pick_folder() + .await + { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + |r| Message::BlockPalette(BlockPaletteMsg::LibraryPicked(r)), + ); + } + BlockPaletteMsg::LibraryPicked(Ok(path)) => self.open_block_library(path), + BlockPaletteMsg::LibrarySelect(path) => self.open_block_library(PathBuf::from(path)), + BlockPaletteMsg::LibraryUp => { + if let Some(parent) = self.block_palette.library_dir.as_ref().and_then(|d| d.parent()) { + let parent = parent.to_path_buf(); + self.list_block_library(parent); + } + } + BlockPaletteMsg::Press(item) => self.block_palette.pressed = Some(item), + BlockPaletteMsg::Release(item) => { + if self.block_palette.pressed.take().as_ref() == Some(&item) { + return match item { + Item::Folder(dir) => { + self.list_block_library(dir); + Task::none() + } + item => self.palette_insert(item, None), + }; + } + } + BlockPaletteMsg::Menu(item, action) => return self.on_block_menu(item, action), + } + Task::none() + } + + /// BLOCKSPALETTE: open (expanded) on `tab`, or on the tab it was left on. + pub(crate) fn open_blocks_palette(&mut self, tab: Option) { + self.show_block_palette = true; + self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette); + self.show_block_palette_tab(tab.unwrap_or(self.block_palette.tab)); + } + + fn show_block_palette_tab(&mut self, tab: Tab) { + self.block_palette.tab = tab; + if tab == Tab::Libraries && self.block_palette.library_dir.is_none() { + let start = Some(self.block_navigate.trim()) + .filter(|p| !p.is_empty() && *p != "." && Path::new(p).is_dir()) + .map(PathBuf::from) + .or_else(|| self.block_palette.libraries.first().map(PathBuf::from)); + if let Some(dir) = start { + self.list_block_library(dir); + } + } + self.refresh_block_palette(); + } + + fn open_block_library(&mut self, root: PathBuf) { + let key = root.display().to_string(); + let libraries = &mut self.block_palette.libraries; + libraries.retain(|l| l != &key); + libraries.insert(0, key); + libraries.truncate(MAX_LIBRARIES); + self.list_block_library(root); + self.persist_settings_if_changed(); + } + + /// Show `dir`: its sub-folders, then its drawings, each by name. + fn list_block_library(&mut self, dir: PathBuf) { + let mut entries: Vec = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let path = e.path(); + // Hidden folders (".name") hold tool data, not blocks. + if e.file_name().to_string_lossy().starts_with('.') { + return None; + } + if path.is_dir() { + Some(LibraryEntry { path, folder: true, thumb: None }) + } else if is_drawing(&path) { + let thumb = crate::io::thumbnail::read_handle(&path); + Some(LibraryEntry { path, folder: false, thumb }) + } else { + None + } + }) + .collect(); + entries.sort_by_key(|e| { + (!e.folder, e.path.file_name().map(|n| n.to_string_lossy().to_lowercase())) + }); + self.block_palette.library_dir = Some(dir); + self.block_palette.library_entries = entries; + } + + fn on_block_option(&mut self, o: OptionMsg) { + let p = &mut self.block_palette; + let number = |t: &str| t.trim().parse::().ok().filter(|v| v.is_finite()); + match o { + OptionMsg::InsertionPoint(v) => p.options.insertion_point = v, + OptionMsg::Scale(v) => p.options.scale = v, + OptionMsg::Uniform(v) => p.options.uniform = v, + OptionMsg::Rotation(v) => p.options.rotation = v, + OptionMsg::AutoPlacement(v) => p.options.auto_placement = v, + OptionMsg::Repeat(v) => p.options.repeat = v, + OptionMsg::Explode(v) => p.options.explode = v, + OptionMsg::X(t) => { + if let Some(v) = number(&t).filter(|v| *v != 0.0) { + p.options.x = v; + } + p.option_text[0] = t; + } + OptionMsg::Y(t) => { + if let Some(v) = number(&t).filter(|v| *v != 0.0) { + p.options.y = v; + } + p.option_text[1] = t; + } + OptionMsg::Z(t) => { + if let Some(v) = number(&t).filter(|v| *v != 0.0) { + p.options.z = v; + } + p.option_text[2] = t; + } + OptionMsg::Angle(t) => { + if let Some(v) = number(&t) { + p.options.angle = v; + } + p.option_text[3] = t; + } + } + self.persist_settings_if_changed(); + } + + /// Start placing `name` as a palette click would. + #[cfg(test)] + pub(super) fn start_block_placement(&mut self, name: &str) { + let _ = self.start_palette_insert(name, None); + } + + fn start_palette_insert(&mut self, name: &str, drop_at: Option) -> Task { + use crate::command::CadCommand; + let i = self.active_tab; + let wires = self + .block_palette + .blocks + .iter() + .find(|b| b.name.eq_ignore_ascii_case(name)) + .map(|b| b.wires.clone()) + .unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name)); + let mut options = self.block_palette.options.clone(); + if drop_at.is_some() { + // A dropped block goes in where it lands with the fixed values. + options.insertion_point = false; + options.scale = false; + options.rotation = false; + options.repeat = false; + } + let mut cmd = InsertBlockCommand::palette(name.to_string(), wires, &options); + if let Some(point) = drop_at { + cmd.set_point(point); + } + self.reset_command_start_state(i); + let plane = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + cmd.set_working_plane(plane); + let first = cmd.initial(); + let prompt = cmd.prompt(); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.block_palette.placing = Some(name.to_string()); + match first { + Some(result) => self.apply_cmd_result(result), + None => { + self.command_line.push_info(&prompt); + Task::none() + } + } + } + + /// Insert `item` (a click, or a drop at `drop_at`), bringing its block + /// into the drawing first when it lives elsewhere. + pub(crate) fn palette_insert(&mut self, item: Item, drop_at: Option) -> Task { + match self.resolve_palette_block(&item) { + Ok(name) => { + self.refresh_block_palette(); + self.start_palette_insert(&name, drop_at) + } + Err(e) => { + if !e.is_empty() { + self.command_line.push_error(&e); + } + Task::none() + } + } + } + + fn block_exists(&self, name: &str) -> bool { + self.tabs[self.active_tab].scene.document.block_records.get(name).is_some() + } + + fn current_drawing_path(&self) -> String { + self.tabs[self.active_tab] + .current_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default() + } + + fn resolve_palette_block(&mut self, item: &Item) -> Result { + match item { + Item::Current(name) => Ok(name.clone()), + Item::Ref(r) if r.drawing => self.block_from_drawing(PathBuf::from(&r.source)), + Item::Ref(r) => { + let elsewhere = !r.source.is_empty() && r.source != self.current_drawing_path(); + if self.block_exists(&r.name) && !(elsewhere && self.block_redefine_mode == 2) { + return Ok(r.name.clone()); + } + if !elsewhere { + return Err(crate::tf!("Block \"{}\" not found.", r.name).into_owned()); + } + let doc = crate::io::load_file(Path::new(&r.source)).map_err(|e| e.to_string())?; + let redefine = self.block_exists(&r.name); + self.import_block_definition(&doc, &r.name, redefine)?; + Ok(r.name.clone()) + } + Item::File(path) => self.block_from_drawing(path.clone()), + Item::Folder(_) => Err(String::new()), + } + } + + /// The block standing for drawing `path`: the drawing's own block when + /// the drawing already has one of that name (redefined from the file + /// under BLOCKREDEFINEMODE 2), else the file inserted as a new block. + fn block_from_drawing(&mut self, path: PathBuf) -> Result { + let stem = file_stem(&path); + let name = if self.block_exists(&stem) && self.block_redefine_mode != 2 { + stem + } else { + self.import_drawing_block(path.clone(), true)? + }; + self.block_palette + .file_sources + .insert(name.to_ascii_uppercase(), path.display().to_string()); + Ok(name) + } + + /// Bring block `name` (and what it needs) from `source` into the active + /// drawing; with `redefine` an existing definition takes the source's. + fn import_block_definition( + &mut self, + source: &codec::CadDocument, + name: &str, + redefine: bool, + ) -> Result<(), String> { + if source.block_records.get(name).is_none() { + return Err(crate::tf!("Block \"{}\" not found.", name).into_owned()); + } + let probe = codec::EntityType::Insert(codec::entities::Insert::new( + name.to_string(), + codec::types::Vector3::ZERO, + )); + let deps = crate::app::ClipboardDeps::capture(source, std::slice::from_ref(&probe)); + let i = self.active_tab; + self.push_undo_snapshot(i, "INSERT"); + self.merge_dependencies(i, &deps); + for def in deps.blocks { + let scene = &mut self.tabs[i].scene; + if redefine && def.name.eq_ignore_ascii_case(name) { + scene.redefine_block_raw(&def.name, def.base_point, def.entities); + } else if scene.document.block_records.get(&def.name).is_none() { + scene.define_block_raw(&def.name, def.base_point, def.entities); + } + } + self.tabs[i].scene.populate_meshes_from_document(); + self.tabs[i].dirty = true; + Ok(()) + } + + fn palette_ref(&self, item: &Item) -> Option { + let time = now_secs(); + match item { + Item::Current(name) => Some(PaletteBlockRef { + name: name.clone(), + source: self.current_drawing_path(), + time, + drawing: false, + }), + Item::Ref(r) => Some(r.clone()), + Item::File(path) => Some(PaletteBlockRef { + name: file_stem(path), + source: path.display().to_string(), + time, + drawing: true, + }), + Item::Folder(_) => None, + } + } + + fn on_block_menu(&mut self, item: Item, action: MenuAction) -> Task { + let same = |a: &PaletteBlockRef, b: &PaletteBlockRef| { + a.name.eq_ignore_ascii_case(&b.name) && a.source == b.source + }; + match action { + MenuAction::Insert => return self.palette_insert(item, None), + MenuAction::Redefine => { + let result = match &item { + Item::Ref(r) if r.drawing => self.import_drawing_block(PathBuf::from(&r.source), true), + Item::File(path) => self.import_drawing_block(path.clone(), true), + Item::Ref(r) if !r.source.is_empty() => crate::io::load_file(Path::new(&r.source)) + .map_err(|e| e.to_string()) + .and_then(|doc| self.import_block_definition(&doc, &r.name, true)) + .map(|_| r.name.clone()), + _ => return Task::none(), + }; + match result { + Ok(name) => { + self.command_line + .push_output(&crate::tf!("Block \"{name}\" redefined.")); + self.refresh_block_palette(); + } + Err(e) => self.command_line.push_error(&e), + } + } + MenuAction::Favorite => { + if let Some(r) = self.palette_ref(&item) { + let favorites = &mut self.block_palette.favorites; + favorites.retain(|f| !same(f, &r)); + favorites.insert(0, r); + self.persist_settings_if_changed(); + self.refresh_block_palette(); + } + } + MenuAction::Unfavorite => { + if let Some(r) = self.palette_ref(&item) { + self.block_palette.favorites.retain(|f| !same(f, &r)); + self.persist_settings_if_changed(); + } + } + MenuAction::Edit => { + let name = item.name(); + let i = self.active_tab; + let reference = self.tabs[i].scene.document.entities().find_map(|e| match e { + codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name) => { + Some(ins.common.handle) + } + _ => None, + }); + match reference { + Some(handle) => return self.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value())), + None => self.command_line.push_error(&crate::tf!( + "Block \"{name}\" has no reference in the drawing to edit." + )), + } + } + MenuAction::Remove => match self.block_palette.tab { + Tab::Current => { + let name = item.name(); + let i = self.active_tab; + let referenced = self.tabs[i].scene.document.entities().any(|e| { + matches!(e, codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name)) + }); + if referenced { + self.command_line.push_error(&crate::tf!( + "Block \"{name}\" is referenced in the drawing and cannot be removed." + )); + } else { + self.push_undo_snapshot(i, "PURGE"); + crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name); + self.tabs[i].dirty = true; + self.command_line.push_output(&crate::tf!("Block \"{name}\" removed.")); + self.refresh_block_palette(); + } + } + Tab::Recent | Tab::Favorites => { + if let Some(r) = self.palette_ref(&item) { + let list = if self.block_palette.tab == Tab::Recent { + &mut self.block_palette.recent + } else { + &mut self.block_palette.favorites + }; + list.retain(|f| !same(f, &r)); + self.persist_settings_if_changed(); + } + } + Tab::Libraries => {} + }, + } + Task::none() + } + + /// A press in the drawing ends any palette press; a release there with + /// a block still pressed drops it at the cursor. + pub(super) fn take_block_drop(&mut self) -> Option { + self.block_palette.pressed.take() + } + + pub(super) fn drop_block(&mut self, item: Item) -> Task { + let at = self.tabs[self.active_tab].last_cursor_world; + self.palette_insert(item, Some(at)) + } + + /// Put `name` at the top of the Recent list (BLOCKMRULIST long). + pub(crate) fn note_recent_block(&mut self, name: &str) { + let (source, drawing) = match self.block_palette.file_sources.get(&name.to_ascii_uppercase()) { + Some(path) => (path.clone(), true), + None => (self.current_drawing_path(), false), + }; + let entry = PaletteBlockRef { + name: if drawing { file_stem(Path::new(&source)) } else { name.to_string() }, + source, + time: now_secs(), + drawing, + }; + let recent = &mut self.block_palette.recent; + recent.retain(|r| !(r.name.eq_ignore_ascii_case(&entry.name) && r.source == entry.source)); + recent.insert(0, entry); + self.trim_recent_blocks(); + } + + pub(crate) fn trim_recent_blocks(&mut self) { + self.block_palette.recent.truncate(self.block_mru_list as usize); + } + + /// Rebuild the Current Drawing list, and the previews kept for blocks + /// of other drawings and for whole drawings. + pub(crate) fn refresh_block_palette(&mut self) { + let i = self.active_tab; + let names = self.tabs[i].scene.custom_block_names(); + self.block_palette.cached_names = names.clone(); + self.block_palette.source_tab_id = Some(self.tabs[i].id); + self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch; + let doc = &self.tabs[i].scene.document; + let annotative = |name: &str| { + doc.block_records.get(name).is_some_and(|br| { + matches!( + doc.get_entity(br.block_entity_handle), + Some(codec::EntityType::Block(b)) if b.common.extended_data.records().iter().any(|r| r.application_name == "AcadAnnotative") + ) + }) + }; + let entries: Vec = names + .into_iter() + .map(|name| BlockEntry { + wires: self.tabs[i].scene.block_preview_wires(&name), + annotative: annotative(&name), + dynamic: false, + name, + }) + .collect(); + let here = self.current_drawing_path(); + for entry in &entries { + let key = format!("{here}|{}", entry.name.to_ascii_uppercase()); + self.block_palette.ref_wires.insert(key, entry.wires.clone()); + } + self.block_palette.blocks = entries; + let drawings: Vec = self + .block_palette + .recent + .iter() + .chain(&self.block_palette.favorites) + .filter(|r| r.drawing && !self.block_palette.file_thumbs.contains_key(&r.source)) + .map(|r| r.source.clone()) + .collect(); + for source in drawings { + let thumb = crate::io::thumbnail::read_handle(Path::new(&source)); + self.block_palette.file_thumbs.insert(source, thumb); + } + } + + /// Cheap per-update check: rebuild when the active drawing's definitions + /// changed, even when their names happen to stay the same. + pub(crate) fn refresh_block_palette_if_stale(&mut self) { + if !self.show_block_palette { + return; + } + let i = self.active_tab; + if self.block_palette.source_tab_id != Some(self.tabs[i].id) + || self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch + { + self.refresh_block_palette(); + } + } + + /// What `-INSERT`'s name step knows about the drawing's blocks. + pub(crate) fn block_catalog(&self) -> BlockCatalog { + let doc = &self.tabs[self.active_tab].scene.document; + let host = doc.header.insertion_units; + let mut user = Vec::new(); + let mut unnamed = 0; + let mut units = rustc_hash::FxHashMap::default(); + for br in doc.block_records.iter() { + if br.flags.is_xref || br.name.eq_ignore_ascii_case("*Model_Space") || br.name.to_ascii_uppercase().starts_with("*PAPER_SPACE") { + continue; + } + if br.name.starts_with('*') { + unnamed += 1; + continue; + } + let factor = crate::app::properties::insert_unit_scale(host, br.units).unwrap_or(1.0); + units.insert( + br.name.to_ascii_uppercase(), + format!( + "Units: {} Conversion:{:>10}", + crate::app::properties::insunits_name(br.units), + crate::app::properties::format_unit_factor(factor) + ), + ); + user.push(br.name.clone()); + } + user.sort_by_key(|n| n.to_ascii_uppercase()); + let search_path = std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + BlockCatalog { user, unnamed, units, search_path } + } +} diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 00d1b8b9e..943ab3d4a 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,7 +2,6 @@ #![allow(unused_imports)] use super::util::*; -use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, @@ -373,21 +372,41 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven /// one new block in the active drawing. Returns the new block's name, or an /// error message. Nested block definitions are imported first so nested /// INSERTs render (AutoCAD's "inserting a drawing imports its block defs"). - fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result { + pub(in crate::app) fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result { + self.import_drawing_block(path, false) + } + + /// [`Self::import_file_as_block`]; with `redefine` a block already named + /// after the file takes the file's contents instead of a new name. + pub(super) fn import_drawing_block( + &mut self, + path: std::path::PathBuf, + redefine: bool, + ) -> Result { let doc = crate::io::load_file(&path).map_err(|e| e.to_string())?; let stem = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "Block".to_string()); - self.import_document_as_block(doc, stem) + self.import_document_block(doc, stem, redefine) } /// Define one block in the active drawing from a loaded `CadDocument`'s /// model-space entities (base = the file's model-space insertion base). + #[cfg(test)] fn import_document_as_block( &mut self, doc: codec::CadDocument, stem: String, + ) -> Result { + self.import_document_block(doc, stem, false) + } + + fn import_document_block( + &mut self, + doc: codec::CadDocument, + stem: String, + redefine: bool, ) -> Result { let i = self.active_tab; // Model-space block record handle (Layout object first, name fallback). @@ -441,7 +460,13 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven doc.header.model_space_insertion_base.y, doc.header.model_space_insertion_base.z, ); - let name = self.block_name_from_file(&stem); + let redefine = redefine + && self.tabs[i].scene.document.block_records.get(stem.trim()).is_some(); + let name = if redefine { + stem.trim().to_string() + } else { + self.block_name_from_file(&stem) + }; // Capture every table record needed by the top-level entities and their // nested block definitions. Importing only the definitions leaves // source-only layers, linetypes, and text/dimension styles dangling. @@ -513,74 +538,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .scene .define_block_raw(&def.name, def.base_point, def.entities); } - self.tabs[i] - .scene - .define_block_from_owned_entities(entities, &name, base)?; + if redefine { + self.tabs[i].scene.redefine_block_raw( + &name, + codec::types::Vector3::new(base.x, base.y, base.z), + entities, + ); + } else { + self.tabs[i] + .scene + .define_block_from_owned_entities(entities, &name, base)?; + } self.tabs[i].scene.populate_meshes_from_document(); self.tabs[i].dirty = true; Ok(name) } - pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task { - use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg}; - match m { - BlockPaletteMsg::Search(s) => { - self.block_palette.search = s; - iced::Task::none() - } - BlockPaletteMsg::CyclePreviewSize => { - self.block_palette.preview_size = - crate::ui::window::block_palette::cycle_preview_size( - self.block_palette.preview_size, - ); - iced::Task::none() - } - BlockPaletteMsg::Refresh => { - self.refresh_block_palette(); - iced::Task::none() - } - BlockPaletteMsg::PickFile => iced::Task::perform( - async { - let handle = rfd::AsyncFileDialog::new() - .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) - .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) - .pick_file() - .await; - match handle { - Some(h) => Ok(crate::sys::handle_path(&h)), - None => Err("Cancelled".to_string()), - } - }, - |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)), - ), - BlockPaletteMsg::FilePicked(Ok(path)) => { - match self.import_file_as_block(path) { - Ok(name) => { - self.command_line - .push_output(&crate::tf!("Inserting \"{name}\" from file.")); - self.refresh_block_palette(); - self.start_block_placement(&name); - } - Err(e) if e != "Cancelled" => { - self.command_line.push_error(&crate::tf!("INSERT FILE: {e}")); - } - Err(_) => {} - } - iced::Task::none() - } - BlockPaletteMsg::FilePicked(Err(e)) => { - if e != "Cancelled" { - self.command_line.push_error(&e); - } - iced::Task::none() - } - BlockPaletteMsg::Insert(name) => { - self.start_block_placement(&name); - iced::Task::none() - } - } - } - /// Dock chrome interaction (grab / pin / resize / hover / move) applied to /// whichever panel the message names. pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { @@ -754,57 +727,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .count() } - /// Start placing `name` through the INSERT command, skipping the name prompt. - fn start_block_placement(&mut self, name: &str) { - let i = self.active_tab; - let wires = self - .block_palette - .blocks - .iter() - .find(|b| b.name == name) - .map(|b| b.wires.clone()) - .unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name)); - use crate::modules::insert::insert_block::InsertBlockCommand; - let cmd = InsertBlockCommand::new_for_block(name.to_string(), wires, glam::Vec3::ZERO); - use crate::command::CadCommand; - self.reset_command_start_state(i); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - self.block_palette.placing = Some(name.to_string()); - } - - /// Rebuild the panel's block list + cached wires from the active drawing. - pub(crate) fn refresh_block_palette(&mut self) { - let i = self.active_tab; - let names = self.tabs[i].scene.custom_block_names(); - self.block_palette.cached_names = names.clone(); - self.block_palette.source_tab_id = Some(self.tabs[i].id); - self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch; - self.block_palette.blocks = names - .into_iter() - .map(|name| { - let wires = self.tabs[i].scene.block_preview_wires(&name); - crate::ui::window::block_palette::BlockEntry { name, wires } - }) - .collect(); - } - - /// Cheap per-update check: rebuild when the active drawing's definitions - /// changed, even when their names happen to stay the same. - pub(crate) fn refresh_block_palette_if_stale(&mut self) { - if !self.show_block_palette { - return; - } - let i = self.active_tab; - // `block_epoch` advances for every block-definition change, so it - // avoids re-scanning every block name on unrelated application updates. - if self.block_palette.source_tab_id != Some(self.tabs[i].id) - || self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch - { - self.refresh_block_palette(); - } - } - /// Rebuild the Reference Manager's entry list from the active drawing. /// Uses the tab's session sets (unloaded + stat baseline) so CLI and /// palette agree; writes fresh load-time mtimes back into the tab's stat @@ -1626,7 +1548,7 @@ mod tests { assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture")); app.start_block_placement(&name); let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running"); - assert_eq!(cmd.name(), "INSERT"); + assert_eq!(cmd.name(), "-INSERT"); assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture")); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bf54ca175..c271e998b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -734,6 +734,14 @@ impl OpenCADStudio { grid_beyond_limits: self.grid_beyond_limits, block_mru: self.block_mru.clone(), block_freq: self.block_freq.clone(), + block_recent: self.block_palette.recent.clone(), + block_favorites: self.block_palette.favorites.clone(), + block_libraries: self.block_palette.libraries.clone(), + block_palette_view: self.block_palette.view as u8, + block_insert: self.block_palette.options.clone(), + block_mru_list: self.block_mru_list, + block_redefine_mode: self.block_redefine_mode, + block_navigate: self.block_navigate.clone(), } } @@ -854,6 +862,15 @@ impl OpenCADStudio { .take(200) .map(|(k, v)| (k.clone(), *v)) .collect(); + self.block_mru_list = s.block_mru_list.min(100); + self.block_redefine_mode = s.block_redefine_mode.min(2); + self.block_navigate = s.block_navigate.clone(); + self.block_palette.recent = s.block_recent.clone(); + self.block_palette.favorites = s.block_favorites.clone(); + self.block_palette.libraries = s.block_libraries.clone(); + self.block_palette.view = crate::ui::window::block_palette::ViewMode::from_u8(s.block_palette_view); + self.block_palette.set_options(s.block_insert.clone()); + self.trim_recent_blocks(); // Push restored display defaults onto every drawing tab that exists now. // Tabs created later pick them up at their construction site. for idx in 0..self.tabs.len() { @@ -863,6 +880,8 @@ impl OpenCADStudio { } pub(crate) fn record_block_insert(&mut self, name: &str) { + self.insname = name.to_string(); + self.note_recent_block(name); let key = name.to_ascii_uppercase(); *self.block_freq.entry(key).or_insert(0) += 1; self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name)); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ce1d7ccd3..50d3fe13a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -92,6 +92,7 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O mod command; mod context_menu; +mod blocks_palette; mod dialog; mod dynamic; mod file; @@ -5768,6 +5769,9 @@ impl OpenCADStudio { if self.tabs[self.active_tab].is_start { self.ribbon.close_dropdown(); } else { + if id == crate::modules::insert::insert_block::GALLERY_ID { + self.refresh_block_palette(); + } self.ribbon.toggle_dropdown(&id); } Task::none() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c12f3646d..7e7867f76 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3356,6 +3356,7 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; + self.block_palette.pressed = None; if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); @@ -3724,6 +3725,10 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_release(&mut self) -> Task { let i = self.active_tab; + // A block dragged from the Blocks palette lands here. + if let Some(item) = self.take_block_drop() { + return self.drop_block(item); + } if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6399320d8..8a357f0b9 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2316,7 +2316,17 @@ bg={bg_ms:.1}ms n={view_count}" } else { Vec::new() }; - let dropdown_layer: Element<'_, Message> = self + let gallery_open = open_dropdown == Some(crate::modules::insert::insert_block::GALLERY_ID) + && !self.tabs[self.active_tab].is_start; + let dropdown_layer: Element<'_, Message> = if gallery_open { + self.ribbon.place_dropdown( + crate::modules::insert::insert_block::GALLERY_ID, + crate::ui::window::block_palette::gallery(&self.block_palette), + crate::ui::window::block_palette::GALLERY_W, + self.win_size.0, + ) + } else { + self .ribbon .dropdown_overlay( &undo_labels, @@ -2325,7 +2335,8 @@ bg={bg_ms:.1}ms n={view_count}" self.tabs[self.active_tab].is_start, &self.recent_colors, ) - .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); + .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()) + }; let snap_override_layer: Element<'_, Message> = if let Some(pos) = self.snap_override_popup { diff --git a/src/command.rs b/src/command.rs index 427c15a1b..885a4ebf9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1888,6 +1888,12 @@ pub enum CmdResult { ReportError(String), /// Reports an error and ends the command (`Lines are parallel.`). CancelWithMessage(String), + /// Explode this block reference and commit its pieces; end the command + /// unless `keep_going` (repeated placement). + CommitExplodedInsert { + insert: EntityType, + keep_going: bool, + }, /// Print a measurement result, clear the current selection, and keep the command active. ReportMeasurementAndDeselect(String), /// Clear the current selection and keep the command active at its updated step. @@ -2926,6 +2932,12 @@ pub trait CadCommand: Send { None } + /// After the host commits an ATTREQ insert: keep the command running + /// (repeated placement) instead of ending it. + fn attreq_continue(&self) -> bool { + false + } + /// Returns the INSERT entity built so far (pending attr fill) if this is an /// ATTREQ-aware INSERT command waiting for attdef injection. /// Called by the host after `AttreqNeeded` to commit the completed Insert. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 2c8c5156d..a4cbb463e 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -163,6 +163,40 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Requires a positive number." => Some(("insert", "pcs-positive")), "Requires a positive integer." => Some(("insert", "pcs-positive-int")), "Traces the points on the kept side of a live section as lines." => Some(("insert", "pcs-help")), + "Add to Favorites" => Some(("insert", "bp-add-favorite")), + "Annotative / Dynamic" => Some(("insert", "bp-annotative-dynamic")), + "Auto-Placement" => Some(("insert", "bp-auto-placement")), + "Blocks from Libraries..." => Some(("insert", "bp-gallery-libraries")), + "Choose a library folder with …" => Some(("insert", "bp-choose-library")), + "Current Drawing" => Some(("insert", "bp-tab-current")), + "Current Drawing Blocks" => Some(("insert", "bp-heading-current")), + "Extra Large Icons" => Some(("insert", "bp-view-xl")), + "Favorite Blocks" => Some(("insert", "bp-heading-favorites")), + "Favorite Blocks..." => Some(("insert", "bp-gallery-favorites")), + "Favorites" => Some(("insert", "bp-tab-favorites")), + "Filter... (* ? wildcards)" => Some(("insert", "bp-filter")), + "Icon" => Some(("insert", "bp-icon")), + "Insertion Point" => Some(("insert", "bp-insertion-point")), + "Large Icons" => Some(("insert", "bp-view-large")), + "Libraries" => Some(("insert", "bp-tab-libraries")), + "Library Blocks" => Some(("insert", "bp-heading-libraries")), + "Medium Icons" => Some(("insert", "bp-view-medium")), + "Recent" => Some(("insert", "bp-tab-recent")), + "Recent Blocks" => Some(("insert", "bp-heading-recent")), + "Recent Blocks..." => Some(("insert", "bp-gallery-recent")), + "Redefine" => Some(("insert", "bp-redefine")), + "Remove Block" => Some(("insert", "bp-remove-block")), + "Remove from Favorites" => Some(("insert", "bp-remove-favorite")), + "Remove from List" => Some(("insert", "bp-remove-list")), + "Repeat Placement" => Some(("insert", "bp-repeat-placement")), + "Select a Block Library Folder" => Some(("insert", "bp-select-library")), + "Small Icons" => Some(("insert", "bp-view-small")), + "specified on screen" => Some(("insert", "bp-on-screen")), + "Block \"{}\" not found." => Some(("insert", "bp-block-not-found")), + "Block \"{name}\" redefined." => Some(("insert", "bp-block-redefined")), + "Block \"{name}\" has no reference in the drawing to edit." => Some(("insert", "bp-block-no-reference")), + "Block \"{name}\" is referenced in the drawing and cannot be removed." => Some(("insert", "bp-block-referenced")), + "Block \"{name}\" removed." => Some(("insert", "bp-block-removed")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index a9db09f43..d8092f026 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -202,7 +202,7 @@ impl CadModule for DrawModule { title: "Block", tools: vec![ RibbonItem::LargeTool(create_block::tool()), - RibbonItem::LargeTool(insert_block::tool()), + insert_block::gallery(), ], }, RibbonGroup { diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 2bf879675..c504cab75 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -1,27 +1,98 @@ +//! Block insertion: `-INSERT` on the command line, a click on the Blocks +//! palette, and paste-as-block. +//! +//! `-INSERT` follows the reference's prompts: +//! Enter block name or [?] : +//! Units: Feet Conversion: 1.0000 +//! Specify insertion point or [Basepoint/Scale/X/Y/Z/Rotate/Explode/REpeat]: +//! Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>: +//! Enter Y scale factor : +//! Specify rotation angle <0>: +//! A palette click asks only what its options leave open, with the shorter +//! option list `[Basepoint/Scale/X/Y/Z/Rotate]`. + use codec::entities::{AttributeDefinition, AttributeEntity, Entity, Insert}; use codec::types::Vector3; use codec::EntityType; use glam::{DVec3, Vec3}; use crate::t; -use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::app::settings::BlockInsertOptions; +use crate::command::{CadCommand, CmdResult, DynField, InputKind, WorkingPlane}; +use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; -pub fn tool() -> ToolDef { - ToolDef { - id: "INSERT", +/// The ribbon's Insert button: its face opens the Blocks palette, its arrow +/// the gallery of the drawing's blocks. +pub const GALLERY_ID: &str = "INSERT_GALLERY"; +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")); +pub const GALLERY_ITEMS: &[(&str, &str, IconKind)] = &[("INSERT", "Insert Block", ICON)]; + +pub fn gallery() -> crate::modules::RibbonItem { + crate::modules::RibbonItem::LargeDropdown { + id: GALLERY_ID, label: "Insert Block", - icon: IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), - event: ModuleEvent::Command("INSERT".to_string()), + icon: ICON, + items: GALLERY_ITEMS.to_vec(), + default: "INSERT", } } +/// What the drawing knows about its blocks, for `-INSERT`'s name step. +#[derive(Clone, Default)] +pub struct BlockCatalog { + /// Named (user) blocks, sorted. + pub user: Vec, + /// How many anonymous (`*`) blocks the drawing holds. + pub unnamed: usize, + /// Each user block's `Units: … Conversion: …` line. + pub units: rustc_hash::FxHashMap, + /// The folder `-INSERT` reports as its search path. + pub search_path: String, +} + +#[derive(Clone, Copy, PartialEq)] +enum Mode { + /// `-INSERT`. + Classic, + /// A Blocks palette click. + Palette, + /// Paste-as-block: only the drop point. + Paste, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Preset { + Base, + Scale, + X, + Y, + Z, + Rotate, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Ask { + Explode, + Repeat, + RepeatScale, + RepeatRotate, +} + enum Step { Name, - Point { - name: String, - }, + ListPattern, + Point, + Preset(Preset), + YesNo(Ask), + ScaleX, + Corner, + XyzX, + XyzY, + XyzZ, + ScaleY, + Uniform, + Rotation, FillAttr { attdefs: Vec, idx: usize, @@ -29,80 +100,299 @@ enum Step { }, } -/// Which numeric value the insertion-point step is currently waiting for after -/// a Scale / Rotate keyword. -#[derive(Clone, Copy)] -enum AwaitKind { - Scale, - Rotation, -} - pub struct InsertBlockCommand { + mode: Mode, picker: crate::modules::insert::picker::BlockPicker, + catalog: BlockCatalog, + default_name: String, step: Step, - /// Uniform X/Y scale applied to the placed block (default 1). - x_scale: f64, - y_scale: f64, - /// Rotation applied to the placed block, in radians (default 0). - rotation_rad: f64, - /// Set while a Scale/Rotate value is being typed at the insertion step. - awaiting: Option, - /// Pending Insert entity stored while attr-filling is in progress. + name: String, + /// Block preview geometry, in block coordinates. + preview: Vec, + /// The point of the block that lands on the insertion point (Basepoint). + base: DVec3, + /// Scales / rotation fixed before the point (options, palette fields). + preset_scale: [Option; 3], + preset_rotation: Option, + /// Palette: the scale is asked after the point, uniformly. + ask_uniform: bool, + explode: bool, + repeat: bool, + repeat_scale: bool, + repeat_rotation: bool, + placed: usize, + point: Option, + scale: [f64; 3], + rotation: f64, pending_insert: Option, - /// Optional drag preview: the block's wire geometry plus the base point it - /// is measured from, so `on_preview_wires` can rubber-band it to the - /// cursor. Set by paste-as-block; empty for a plain INSERT. - preview: Option<(Vec, Vec3)>, plane: WorkingPlane, } impl InsertBlockCommand { - /// Construct with explicit usage ranking. `usage` maps uppercase block name → (frequency, MRU position). - /// `cliprompt_lines` directly controls suggestion count (relation per spec). - pub fn new_with_usage( + fn base_state(mode: Mode, picker: crate::modules::insert::picker::BlockPicker) -> Self { + Self { + mode, + picker, + catalog: BlockCatalog::default(), + default_name: String::new(), + step: Step::Name, + name: String::new(), + preview: Vec::new(), + base: DVec3::ZERO, + preset_scale: [None; 3], + preset_rotation: None, + ask_uniform: false, + explode: false, + repeat: false, + repeat_scale: false, + repeat_rotation: false, + placed: 0, + point: None, + scale: [1.0; 3], + rotation: 0.0, + pending_insert: None, + plane: WorkingPlane::default(), + } + } + + /// `-INSERT`: the name step first. `default_name` is INSNAME. + pub fn classic( available: Vec, usage_rank: rustc_hash::FxHashMap, cliprompt_lines: u8, + catalog: BlockCatalog, + default_name: String, ) -> Self { let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); - Self { - picker, - step: Step::Name, - x_scale: 1.0, - y_scale: 1.0, - rotation_rad: 0.0, - awaiting: None, - pending_insert: None, - preview: None, - plane: WorkingPlane::default(), - } + let mut command = Self::base_state(Mode::Classic, picker); + command.catalog = catalog; + command.default_name = default_name; + command } - /// Start the command already locked to `name`, skipping the name prompt and - /// going straight to "specify insertion point". `preview_wires` (measured - /// from `base`) rubber-band under the cursor. Used by paste-as-block, which - /// has just defined the block and only needs the drop point. + /// `-INSERT` already given a block (a drawing just inserted as a block). + pub fn classic_for_block(name: String, preview: Vec, units_line: String) -> Self { + let mut command = Self::base_state(Mode::Classic, Self::single_picker(&name)); + command.catalog.units.insert(name.to_ascii_uppercase(), units_line); + command.name = name; + command.preview = preview; + command.step = Step::Point; + command + } + + /// A Blocks palette click with the palette's options. + pub fn palette(name: String, preview: Vec, options: &BlockInsertOptions) -> Self { + let mut command = Self::base_state(Mode::Palette, Self::single_picker(&name)); + command.name = name; + command.preview = preview; + if !options.scale { + let x = options.x; + command.preset_scale = if options.uniform { + [Some(x); 3] + } else { + [Some(options.x), Some(options.y), Some(options.z)] + }; + } else { + command.ask_uniform = options.uniform; + } + if !options.rotation { + command.preset_rotation = Some(options.angle.to_radians()); + } + command.explode = options.explode; + command.repeat = options.repeat; + command.repeat_scale = options.scale; + command.repeat_rotation = options.rotation; + command.step = Step::Point; + if !options.insertion_point { + command.point = Some(DVec3::ZERO); + } + command + } + + /// Paste-as-block: the block is placed at the picked point. pub fn new_for_block(name: String, preview_wires: Vec, base: Vec3) -> Self { - // Minimal picker for the locked-name path; not used for Name step. - let picker = crate::modules::insert::picker::BlockPicker::new( - vec![name.clone()], + let mut command = Self::base_state(Mode::Paste, Self::single_picker(&name)); + command.name = name; + command.preview = preview_wires; + command.base = base.as_dvec3(); + command.preset_scale = [Some(1.0); 3]; + command.preset_rotation = Some(0.0); + command.step = Step::Point; + command + } + + fn single_picker(name: &str) -> crate::modules::insert::picker::BlockPicker { + crate::modules::insert::picker::BlockPicker::new( + vec![name.to_string()], rustc_hash::FxHashMap::default(), 0, - ); - Self { - picker, - step: Step::Point { name }, - x_scale: 1.0, - y_scale: 1.0, - rotation_rad: 0.0, - awaiting: None, - pending_insert: None, - preview: Some((preview_wires, base)), - plane: WorkingPlane::default(), + ) + } + + /// A block dropped from the palette: it goes in at `point` (world). + pub fn set_point(&mut self, point: DVec3) { + self.point = Some(point); + } + + /// A palette click with the insertion point turned off and nothing to + /// ask: the block goes in at once. The host applies this result before + /// showing a prompt. + pub fn initial(&mut self) -> Option { + if self.point.is_some() && matches!(self.step, Step::Point) { + return Some(self.after_point()); + } + None + } + + fn options_text(&self) -> &'static str { + match self.mode { + Mode::Classic => "[Basepoint/Scale/X/Y/Z/Rotate/Explode/REpeat]", + _ => "[Basepoint/Scale/X/Y/Z/Rotate]", } } + /// After the insertion point: ask what is still open, else place. + fn after_point(&mut self) -> CmdResult { + if self.preset_scale[0].is_none() { + self.step = if self.ask_uniform { Step::Uniform } else { Step::ScaleX }; + return CmdResult::NeedPoint; + } + self.scale = [ + self.preset_scale[0].unwrap_or(1.0), + self.preset_scale[1].unwrap_or(1.0), + self.preset_scale[2].unwrap_or(1.0), + ]; + self.after_scale() + } + + fn after_scale(&mut self) -> CmdResult { + match self.preset_rotation { + Some(r) => { + self.rotation = r; + self.place() + } + None => { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + } + + /// Build the reference at the chosen point and hand it over. + fn place(&mut self) -> CmdResult { + let Some(point) = self.point.take() else { + return CmdResult::NeedPoint; + }; + let local = self.plane.to_local(point); + let offset = self.local_offset(self.base); + let mut ins = Insert::new( + self.name.clone(), + Vector3::new(local.x - offset.x, local.y - offset.y, local.z - offset.z), + ); + ins.set_x_scale(self.scale[0]); + ins.set_y_scale(self.scale[1]); + ins.set_z_scale(self.scale[2]); + ins.rotation = self.rotation; + if !self.plane.is_identity() { + ins.apply_transform(&self.plane.to_world_transform()); + } + self.placed += 1; + // Repeating: later blocks keep the first one's scale / rotation + // unless each block is to be asked. + if self.repeat { + if !self.repeat_scale { + self.preset_scale = self.scale.map(Some); + } + if !self.repeat_rotation { + self.preset_rotation = Some(self.rotation); + } + self.step = Step::Point; + } + if self.explode { + return CmdResult::CommitExplodedInsert { + insert: EntityType::Insert(ins), + keep_going: self.repeat, + }; + } + let block_name = self.name.clone(); + self.pending_insert = Some(ins); + CmdResult::AttreqNeeded { block_name } + } + + /// The scaled, rotated block-space offset of `p`. + fn local_offset(&self, p: DVec3) -> DVec3 { + let s = DVec3::new(p.x * self.scale[0], p.y * self.scale[1], p.z * self.scale[2]); + let (sin, cos) = self.rotation.sin_cos(); + DVec3::new(s.x * cos - s.y * sin, s.x * sin + s.y * cos, s.z) + } + + fn number(text: &str) -> Option { + let t = text.trim().replace(',', "."); + t.parse::().ok().filter(|v| v.is_finite()) + } + + fn yes_no(text: &str, default: bool) -> Option { + match text.trim().to_ascii_uppercase().as_str() { + "" => Some(default), + "Y" | "YES" => Some(true), + "N" | "NO" => Some(false), + _ => None, + } + } + + /// `-INSERT ?`: the reference's listing. + fn listing(&self, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() }; + let mut out = String::from("Defined blocks."); + let mut shown = 0; + for name in &self.catalog.user { + if pattern.split(',').any(|p| crate::io::xref_model::wildcard_match(name, p.trim())) { + out.push_str(&format!("\n \"{name}\"")); + shown += 1; + } + } + let _ = shown; + out.push_str(&format!( + "\n\nUser Unnamed\nBlocks Blocks\n{:>5}{:>9}", + self.catalog.user.len(), + self.catalog.unnamed + )); + out + } + + /// The block name chosen at the name step. + fn choose(&mut self, name: String) -> CmdResult { + let units = self + .catalog + .units + .get(&name.to_ascii_uppercase()) + .cloned() + .unwrap_or_default(); + self.name = name; + self.step = Step::Point; + if units.is_empty() { + CmdResult::NeedPoint + } else { + CmdResult::ReportMeasurement(units) + } + } + + fn not_found(&self, name: &str) -> CmdResult { + let file = if name.to_ascii_lowercase().ends_with(".dwg") { + name.to_string() + } else { + format!("{name}.dwg") + }; + let path = std::path::Path::new(&file); + if path.is_file() { + return CmdResult::Dispatch(format!("_-INSERTFILE {}", path.display())); + } + CmdResult::CancelWithMessage(format!( + "\"{file}\": Can't find file in search path:\n {} (current directory)\n*Invalid*", + self.catalog.search_path + )) + } } impl CadCommand for InsertBlockCommand { @@ -111,55 +401,59 @@ impl CadCommand for InsertBlockCommand { } fn name(&self) -> &'static str { - "INSERT" + match self.mode { + Mode::Paste => "INSERT", + _ => "-INSERT", + } } fn prompt(&self) -> String { match &self.step { Step::Name => { - if self.picker.is_empty() { - return t!("INSERT Enter block name:").into_owned(); - } - let needle = self.picker.needle(); - let filtered = self.picker.filtered(); - if !needle.is_empty() && filtered.is_empty() { - return t!( - "INSERT No matching blocks for \"%{needle}\"", - needle = needle - ) - .into_owned(); - } - if needle.is_empty() { - let total = self.picker.total(); - let shown = filtered.len(); - if total <= shown { - t!("INSERT Enter block name:").into_owned() - } else { - t!( - "INSERT Enter block name: [%{shown} of %{total} — type to search]", - shown = shown, - total = total - ) - .into_owned() - } + if self.default_name.is_empty() { + "Enter block name or [?]:".to_string() } else { - t!( - "INSERT Enter block name: \"%{needle}\" [%{shown} matches]", - needle = needle, - shown = filtered.len() - ) - .into_owned() + format!("Enter block name or [?] <{}>:", self.default_name) } } - Step::Point { name } => match self.awaiting { - Some(AwaitKind::Scale) => t!("INSERT Specify scale factor <1>:").into_owned(), - Some(AwaitKind::Rotation) => t!("INSERT Specify rotation angle <0>:").into_owned(), - None => t!( + Step::ListPattern => "Enter block(s) to list <*>:".to_string(), + Step::Point => match self.mode { + Mode::Paste => t!( "INSERT Specify insertion point for \"%{name}\" [Scale/Rotate]:", - name = name + name = self.name ) .into_owned(), + _ => format!("Specify insertion point or {}:", self.options_text()), }, + Step::Preset(Preset::Base) => "Specify base point:".to_string(), + Step::Preset(Preset::Scale) => match self.mode { + Mode::Paste => t!("INSERT Specify scale factor <1>:").into_owned(), + _ => "Specify scale factor for XYZ axes <1>:".to_string(), + }, + Step::Preset(Preset::X) => "Specify X scale factor <1>:".to_string(), + Step::Preset(Preset::Y) => "Specify Y scale factor <1>:".to_string(), + Step::Preset(Preset::Z) => "Specify Z scale factor <1>:".to_string(), + Step::Preset(Preset::Rotate) => match self.mode { + Mode::Paste => t!("INSERT Specify rotation angle <0>:").into_owned(), + _ => "Specify rotation angle <0>:".to_string(), + }, + Step::YesNo(Ask::Explode) => "Explode block [Yes/No] :".to_string(), + Step::YesNo(Ask::Repeat) => "Repeat insertion of block [Yes/No] :".to_string(), + Step::YesNo(Ask::RepeatScale) => { + "Enter a scale factor for each block [Yes/No] ".to_string() + } + Step::YesNo(Ask::RepeatRotate) => { + "Enter a rotation angle for each block [Yes/No] ".to_string() + } + Step::ScaleX => { + "Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>:".to_string() + } + Step::Corner => "Specify opposite corner:".to_string(), + Step::XyzX => "Specify X scale factor or [Corner] <1>:".to_string(), + Step::XyzY | Step::ScaleY => "Enter Y scale factor :".to_string(), + Step::XyzZ => "Specify Z scale factor or :".to_string(), + Step::Uniform => "Specify scale factor for XYZ axes <1>:".to_string(), + Step::Rotation => "Specify rotation angle <0>:".to_string(), Step::FillAttr { attdefs, idx, .. } => { if let Some(ad) = attdefs.get(*idx) { let default_hint = if ad.default_value.is_empty() { @@ -186,24 +480,40 @@ impl CadCommand for InsertBlockCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match &self.step { - Step::Name => CmdResult::NeedPoint, - Step::Point { name } => { - let point = self.plane.to_local(pt); - let mut ins = Insert::new( - name.clone(), - Vector3::new(point.x, point.y, point.z), - ); - ins.set_x_scale(self.x_scale); - ins.set_y_scale(self.y_scale); - ins.rotation = self.rotation_rad; - ins.apply_transform(&self.plane.to_world_transform()); - let block_name = name.clone(); - self.pending_insert = Some(ins); - // Signal the host to check for attdefs. - CmdResult::AttreqNeeded { block_name } + match self.step { + Step::Point => { + self.point = Some(pt); + self.after_point() } - Step::FillAttr { .. } => CmdResult::NeedPoint, + Step::Preset(Preset::Base) => { + self.base = self.plane.to_local(pt); + self.step = Step::Point; + CmdResult::NeedPoint + } + Step::ScaleX | Step::Corner | Step::XyzX => { + let Some(point) = self.point else { + return CmdResult::NeedPoint; + }; + let d = self.plane.to_local(pt) - self.plane.to_local(point); + if d.x.abs() < 1e-12 || d.y.abs() < 1e-12 { + return CmdResult::ReportError("Value must be nonzero.".to_string()); + } + self.scale = [d.x, d.y, d.x]; + self.after_scale() + } + Step::Rotation => { + let Some(point) = self.point else { + return CmdResult::NeedPoint; + }; + match self.plane.angle(point, pt) { + Some(angle) => { + self.rotation = angle; + self.place() + } + None => CmdResult::NeedPoint, + } + } + _ => CmdResult::NeedPoint, } } @@ -223,10 +533,8 @@ impl CadCommand for InsertBlockCommand { if !matches!(self.step, Step::Name) { return false; } - // Performance: only recompute if needle actually changed; picker - // uses upper/lower caches and partial sort so per-keystroke is <0.2ms. let needle = input.trim(); - if needle == self.picker.needle() { + if needle == self.picker.needle() || needle == "?" { return false; } self.picker.set_needle(needle.to_string()); @@ -234,114 +542,273 @@ impl CadCommand for InsertBlockCommand { } fn on_enter(&mut self) -> CmdResult { - match &self.step { - // A bare Enter while a scale/rotation value is awaited keeps the - // current default instead of cancelling the command. - Step::Point { .. } if self.awaiting.is_some() => { - self.awaiting = None; + match self.step { + Step::Name => { + if self.default_name.is_empty() { + CmdResult::Cancel + } else { + let name = self.default_name.clone(); + match self.picker.contains_name(&name) { + Some(canonical) => self.choose(canonical), + None => self.not_found(&name), + } + } + } + Step::ListPattern => CmdResult::Measurement(self.listing("*")), + Step::Point => { + if self.repeat && self.placed > 0 { + CmdResult::Cancel + } else if self.mode == Mode::Paste { + CmdResult::Cancel + } else { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + } + Step::Preset(_) => { + self.step = Step::Point; CmdResult::NeedPoint } - Step::Name | Step::Point { .. } => CmdResult::Cancel, - Step::FillAttr { .. } => { - // Treat Enter as accepting the default. - self.accept_attr_value("") + Step::YesNo(ask) => self.answer(ask, None), + Step::ScaleX | Step::XyzX => { + self.scale[0] = 1.0; + self.step = if matches!(self.step, Step::XyzX) { Step::XyzY } else { Step::ScaleY }; + CmdResult::NeedPoint } + Step::XyzY => { + self.scale[1] = self.scale[0]; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::ScaleY => { + self.scale[1] = self.scale[0]; + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::Uniform => { + self.scale = [1.0; 3]; + self.after_scale() + } + Step::Rotation => { + self.rotation = 0.0; + self.place() + } + Step::Corner => CmdResult::NeedPoint, + Step::FillAttr { .. } => self.accept_attr_value(""), } } fn input_kind(&self) -> InputKind { match self.step { Step::FillAttr { .. } => InputKind::FreeText, - Step::Name | Step::Point { .. } => InputKind::SingleToken, + Step::Name | Step::ListPattern | Step::YesNo(_) => InputKind::SingleToken, + Step::Point | Step::Preset(Preset::Base) | Step::Corner => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation | Step::Preset(Preset::Rotate) => DynField::Angle, + Step::ScaleX + | Step::ScaleY + | Step::XyzX + | Step::XyzY + | Step::XyzZ + | Step::Uniform + | Step::Preset(_) => DynField::Scalar, + _ => DynField::Point, } } fn point_step_accepts_keywords(&self) -> bool { - // The insertion-point step also takes Scale / Rotate keywords while - // still accepting a point pick. Once a value is being typed (awaiting), - // route the whole number through `on_text_input` instead. - matches!(self.step, Step::Point { .. }) && self.awaiting.is_none() + matches!(self.step, Step::Point) } fn on_text_input(&mut self, text: &str) -> Option { - match &self.step { + let t = text.trim(); + let up = t.to_ascii_uppercase(); + Some(match self.step { Step::Name => { - let name = text.trim(); - // Empty input: reset needle to show default ranked list (2.1). - // Consumed so prompt+buttons refresh, not stale filter. - if name.is_empty() { + if t == "?" { + self.step = Step::ListPattern; + return Some(CmdResult::NeedPoint); + } + if t.is_empty() { self.picker.set_needle(String::new()); return Some(CmdResult::NeedPoint); } - // Exact block name (case-insensitive) → accept and go to point step. - // This path is used both for typed exact names and for CmdOption - // button clicks (Message::CommandOptionPick feeds the keyword through - // on_text_input). Returning NeedPoint keeps the prompt updated. - if let Some(canonical) = self.picker.contains_name(name) { - self.step = Step::Point { name: canonical }; - return Some(CmdResult::NeedPoint); + match self.picker.contains_name(t) { + Some(canonical) => self.choose(canonical), + None => self.not_found(t), } - // Not an exact match → treat as incremental search needle. - // Update filter and re-render prompt + option buttons. Must return - // Some(NeedPoint) (consumed) not None, otherwise the driver would - // offer the same text to the command a second time. - self.picker.set_needle(name.to_string()); - Some(CmdResult::NeedPoint) } - Step::FillAttr { .. } => Some(self.accept_attr_value(text)), - Step::Point { .. } => { - // Typing the value awaited after a Scale / Rotate keyword. The - // two read differently: a scale is a plain multiplier with no - // unit to write it in, while a rotation is an angle and takes - // whichever convention the drawing is set to. - if let Some(kind) = self.awaiting { - match kind { - AwaitKind::Scale => { - if let Ok(v) = text.trim().parse::() { - if v != 0.0 { - self.x_scale = v; - self.y_scale = v; - } - } - } - AwaitKind::Rotation => { - if let Some(v) = crate::entities::common::parse_typed_angle(text) { - self.rotation_rad = v; - } + Step::ListPattern => CmdResult::Measurement(self.listing(t)), + Step::Point => match up.as_str() { + "B" | "BASEPOINT" => { + self.step = Step::Preset(Preset::Base); + CmdResult::NeedPoint + } + "S" | "SCALE" => { + self.step = Step::Preset(Preset::Scale); + CmdResult::NeedPoint + } + "X" => { + self.step = Step::Preset(Preset::X); + CmdResult::NeedPoint + } + "Y" => { + self.step = Step::Preset(Preset::Y); + CmdResult::NeedPoint + } + "Z" => { + self.step = Step::Preset(Preset::Z); + CmdResult::NeedPoint + } + "R" | "ROTATE" => { + self.step = Step::Preset(Preset::Rotate); + CmdResult::NeedPoint + } + "E" | "EXPLODE" if self.mode == Mode::Classic => { + self.step = Step::YesNo(Ask::Explode); + CmdResult::NeedPoint + } + "RE" | "REPEAT" if self.mode == Mode::Classic => { + self.step = Step::YesNo(Ask::Repeat); + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + Step::Preset(kind) => { + if kind == Preset::Rotate { + match crate::entities::common::parse_typed_angle(t) { + Some(angle) => self.preset_rotation = Some(angle), + None => { + return Some(CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + )) } } - self.awaiting = None; - return Some(CmdResult::NeedPoint); - } - match text.trim().to_uppercase().as_str() { - "S" | "SCALE" => { - self.awaiting = Some(AwaitKind::Scale); - Some(CmdResult::NeedPoint) + } else { + match Self::number(t) { + Some(v) if v != 0.0 => match kind { + Preset::Scale => self.preset_scale = [Some(v); 3], + Preset::X => self.preset_scale[0] = Some(v), + Preset::Y => self.preset_scale[1] = Some(v), + Preset::Z => self.preset_scale[2] = Some(v), + _ => {} + }, + Some(_) => { + return Some(CmdResult::ReportError("Value must be nonzero.".to_string())) + } + None => { + return Some(CmdResult::ReportError( + "Requires numeric value.".to_string(), + )) + } } - "R" | "ROTATE" => { - self.awaiting = Some(AwaitKind::Rotation); - Some(CmdResult::NeedPoint) + // A single axis given: the others stay at 1. + if self.preset_scale.iter().any(Option::is_some) { + for axis in &mut self.preset_scale { + axis.get_or_insert(1.0); + } } - _ => None, } + self.step = Step::Point; + CmdResult::NeedPoint } - } + Step::YesNo(ask) => self.answer(ask, Some(t)), + Step::ScaleX | Step::XyzX => match up.as_str() { + "C" | "CORNER" => { + self.step = Step::Corner; + CmdResult::NeedPoint + } + "XYZ" if matches!(self.step, Step::ScaleX) => { + self.step = Step::XyzX; + CmdResult::NeedPoint + } + _ => match Self::number(t) { + Some(v) if v != 0.0 => { + self.scale[0] = v; + self.step = if matches!(self.step, Step::XyzX) { Step::XyzY } else { Step::ScaleY }; + CmdResult::NeedPoint + } + Some(_) => CmdResult::ReportError("Value must be nonzero.".to_string()), + None => CmdResult::ReportError("Requires numeric value.".to_string()), + }, + }, + Step::ScaleY | Step::XyzY | Step::XyzZ | Step::Uniform => match Self::number(t) { + Some(v) if v != 0.0 => match self.step { + Step::ScaleY => { + self.scale[1] = v; + self.scale[2] = self.scale[0]; + self.after_scale() + } + Step::XyzY => { + self.scale[1] = v; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = v; + self.after_scale() + } + _ => { + self.scale = [v; 3]; + self.after_scale() + } + }, + Some(_) => CmdResult::ReportError("Value must be nonzero.".to_string()), + None => CmdResult::ReportError("Requires numeric value.".to_string()), + }, + Step::Rotation => match crate::entities::common::parse_typed_angle(t) { + Some(angle) => { + self.rotation = angle; + self.place() + } + None => CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + ), + }, + Step::Corner => CmdResult::NeedPoint, + Step::FillAttr { .. } => self.accept_attr_value(text), + }) } - fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let pt = pt.as_vec3(); - match (&self.step, &self.preview) { - (Step::Point { .. }, Some((wires, base))) => { - let delta = pt - *base; - wires.iter().map(|w| w.translated(delta)).collect() - } - _ => vec![], + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if self.preview.is_empty() || !matches!(self.step, Step::Point) { + return Vec::new(); } + let scale = [ + self.preset_scale[0].unwrap_or(1.0), + self.preset_scale[1].unwrap_or(1.0), + self.preset_scale[2].unwrap_or(1.0), + ]; + let (sin, cos) = self.preset_rotation.unwrap_or(0.0).sin_cos(); + let base = self.base; + let origin = self.plane.to_local(pt); + let plane = self.plane; + self.preview + .iter() + .map(|w| { + w.mapped(|p| { + let d = p - base; + let s = DVec3::new(d.x * scale[0], d.y * scale[1], d.z * scale[2]); + let r = DVec3::new(s.x * cos - s.y * sin, s.x * sin + s.y * cos, s.z); + plane.to_world(origin + r) + }) + }) + .collect() } - fn attreq_set_attdefs( - &mut self, - attdefs: Vec, - ) -> Option { + fn attreq_continue(&self) -> bool { + self.repeat + } + + fn attreq_set_attdefs(&mut self, attdefs: Vec) -> Option { self.step = Step::FillAttr { attdefs, idx: 0, @@ -351,13 +818,37 @@ impl CadCommand for InsertBlockCommand { } fn attreq_take_insert(&mut self) -> Option { - self.pending_insert - .take() - .map(|ins| EntityType::Insert(ins)) + self.pending_insert.take().map(EntityType::Insert) } } impl InsertBlockCommand { + fn answer(&mut self, ask: Ask, text: Option<&str>) -> CmdResult { + let default = !matches!(ask, Ask::RepeatScale | Ask::RepeatRotate); + let Some(yes) = Self::yes_no(text.unwrap_or(""), default) else { + return CmdResult::ReportError("Invalid option keyword.".to_string()); + }; + self.step = match ask { + Ask::Explode => { + self.explode = yes; + Step::Point + } + Ask::Repeat => { + self.repeat = yes; + if yes { Step::YesNo(Ask::RepeatScale) } else { Step::Point } + } + Ask::RepeatScale => { + self.repeat_scale = yes; + Step::YesNo(Ask::RepeatRotate) + } + Ask::RepeatRotate => { + self.repeat_rotation = yes; + Step::Point + } + }; + CmdResult::NeedPoint + } + fn accept_attr_value(&mut self, text: &str) -> CmdResult { let (attdef_idx, default) = match &self.step { Step::FillAttr { attdefs, idx, .. } => { @@ -368,13 +859,11 @@ impl InsertBlockCommand { } _ => return CmdResult::Cancel, }; - let value = if text.trim().is_empty() { default } else { text.trim().to_string() }; - if let Step::FillAttr { ref mut values, ref mut idx, @@ -384,8 +873,11 @@ impl InsertBlockCommand { values.push((attdef_idx, value)); *idx = attdef_idx + 1; } - match self.advance_automatic_attributes() { + Some(entity) if self.repeat => { + self.step = Step::Point; + CmdResult::CommitEntity(entity) + } Some(entity) => CmdResult::CommitAndExit(entity), None => CmdResult::NeedPoint, } @@ -416,9 +908,7 @@ impl InsertBlockCommand { fn finish_insert(&mut self) -> Option { let (attdefs, values) = match &self.step { - Step::FillAttr { - attdefs, values, .. - } => (attdefs.clone(), values.clone()), + Step::FillAttr { attdefs, values, .. } => (attdefs.clone(), values.clone()), _ => return None, }; let mut insert = self.pending_insert.take()?; @@ -431,10 +921,12 @@ impl InsertBlockCommand { attribute.apply_transform(&transform); insert.attributes.push(attribute); } + if self.repeat { + self.step = Step::Point; + } Some(EntityType::Insert(insert)) } } - // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["INSERT"] }); // InsertBlockCommand +inventory::submit!(crate::command::CommandRegistration { names: &["INSERT", "-INSERT"] }); diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index b79112659..573ce53d2 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -105,7 +105,7 @@ impl CadModule for InsertModule { title: "Block", tools: vec![ RibbonItem::LargeTool(mview_block::tool()), - RibbonItem::LargeTool(insert_block::tool()), + insert_block::gallery(), RibbonItem::Tool(create_block::tool()), RibbonItem::Tool(edit_block::tool()), RibbonItem::Tool(base_point::tool()), diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 067b4cbf4..6108ac028 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1234,6 +1234,46 @@ impl Scene { self.bump_geometry(); } + /// Replace the contents and base point of block `name`, defining it when + /// absent. References keep pointing at it and show the new contents. + pub fn redefine_block_raw( + &mut self, + name: &str, + base_point: codec::types::Vector3, + entities: Vec, + ) { + let Some((br_handle, block_handle)) = self + .document + .block_records + .get(name) + .map(|br| (br.handle, br.block_entity_handle)) + else { + return self.define_block_raw(name, base_point, entities); + }; + let owned: Vec = self + .document + .entities() + .filter(|e| { + e.common().owner_handle == br_handle + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .map(|e| e.common().handle) + .collect(); + for h in owned { + self.document.remove_entity(h); + } + if let Some(EntityType::Block(block)) = self.document.get_entity_mut(block_handle) { + block.base_point = base_point; + } + for mut entity in entities { + Self::reset_clone_subhandles(&mut self.document, &mut entity); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = br_handle; + let _ = self.document.add_entity(entity); + } + self.bump_geometry(); + } + pub(super) fn synced_hatch_models( &self, target_block: Handle, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index dae84c28f..a21ee014b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -20,7 +20,7 @@ use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem}; use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; -mod widgets; +pub(crate) mod widgets; mod draw_panel; pub(crate) use draw_panel::tools as panel_tools; mod modify_panel; @@ -745,6 +745,19 @@ impl Ribbon { // ── Dropdown overlay ────────────────────────────────────────────────── + /// Place `panel` (`w` wide) under dropdown `id`, closing on a click + /// outside it. + pub fn place_dropdown<'a>( + &self, + id: &str, + panel: Element<'a, Message>, + w: f32, + win_w: f32, + ) -> Element<'a, Message> { + let (align_right, h_pad, top) = self.dd_anchor(id, w, win_w); + dropdown_backdrop(position_ribbon_dropdown(panel, align_right, h_pad, top)) + } + pub fn dropdown_overlay( &self, undo_labels: &[String], diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index c5ea6acfa..74e7d9393 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -446,7 +446,7 @@ pub(super) fn combo_btn_style( } } -pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style { +pub(crate) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style { let palette = theme.palette(); let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) { palette.background.weak @@ -460,7 +460,7 @@ pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button:: } } -pub(super) fn popup_panel_style(theme: &Theme) -> container::Style { +pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.base.color)), diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index c5129e45b..d6f85d30c 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -1,92 +1,293 @@ -//! Docked "Insert Block" panel: a searchable grid of block thumbnails that -//! inserts a clicked block, mirrors the existing `InsertBlockCommand` flow. +//! The docked Blocks palette: Current Drawing, Recent, Favorites and +//! Libraries tabs over one block list, a wildcard filter, six views, and the +//! insertion options. A click on a block starts placing it with the options; +//! a block dragged onto the drawing goes in where it is dropped. +use std::path::PathBuf; + +use crate::app::settings::{BlockInsertOptions, PaletteBlockRef}; use crate::app::Message; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; use crate::ui::dock::PanelId; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; +use crate::ui::window::pdf_dialogs::{accent_text, card_style, well_style}; use iced::widget::canvas::{Frame, Path, Program, Stroke}; -use iced::widget::{button, canvas, column, container, row, scrollable, text, text_input}; +use iced::widget::{ + button, canvas, checkbox, column, container, image, mouse_area, pick_list, row, scrollable, + text, text_input, Space, +}; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; const TOOL_H: f32 = 22.0; -/// Character budget for a block card's label so all rows stay one line. -/// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping. -const MAX_LABEL_CHARS: usize = 12; -/// Fixed single-line height of a card's label. Clips any text that still -/// won't fit so every cell in a grid row shares the same height. +/// Fixed single-line height of a card's label so every cell in a row +/// shares the same height. const LABEL_LINE_H: f32 = 16.0; +/// The palette's tabs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Current, + Recent, + Favorites, + Libraries, +} + +impl Tab { + fn label(self) -> String { + match self { + Tab::Current => crate::t!("Current Drawing"), + Tab::Recent => crate::t!("Recent"), + Tab::Favorites => crate::t!("Favorites"), + Tab::Libraries => crate::t!("Libraries"), + } + .into_owned() + } + + fn heading(self) -> String { + match self { + Tab::Current => crate::t!("Current Drawing Blocks"), + Tab::Recent => crate::t!("Recent Blocks"), + Tab::Favorites => crate::t!("Favorite Blocks"), + Tab::Libraries => crate::t!("Library Blocks"), + } + .into_owned() + } +} + +/// How the list is drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewMode { + ExtraLarge, + #[default] + Large, + Medium, + Small, + Details, + List, +} + +impl ViewMode { + pub const ALL: [ViewMode; 6] = [ + ViewMode::ExtraLarge, + ViewMode::Large, + ViewMode::Medium, + ViewMode::Small, + ViewMode::Details, + ViewMode::List, + ]; + + pub fn from_u8(v: u8) -> Self { + Self::ALL.get(v as usize).copied().unwrap_or_default() + } + + pub fn label(self) -> String { + match self { + ViewMode::ExtraLarge => crate::t!("Extra Large Icons"), + ViewMode::Large => crate::t!("Large Icons"), + ViewMode::Medium => crate::t!("Medium Icons"), + ViewMode::Small => crate::t!("Small Icons"), + ViewMode::Details => crate::t!("Details"), + ViewMode::List => crate::t!("List"), + } + .into_owned() + } + + /// Cards per row and thumbnail height for the icon views. + fn grid(self) -> Option<(usize, f32)> { + match self { + ViewMode::ExtraLarge => Some((2, 120.0)), + ViewMode::Large => Some((3, 70.0)), + ViewMode::Medium => Some((4, 46.0)), + _ => None, + } + } +} + +/// One thing the list shows. +#[derive(Debug, Clone, PartialEq)] +pub enum Item { + /// A block of the current drawing. + Current(String), + /// A recent or favorite block, possibly from another drawing. + Ref(PaletteBlockRef), + /// A drawing in a library folder (inserted as a block). + File(PathBuf), + /// A sub-folder of a library folder. + Folder(PathBuf), +} + +impl Item { + pub fn name(&self) -> String { + match self { + Item::Current(name) => name.clone(), + Item::Ref(r) => r.name.clone(), + Item::File(p) | Item::Folder(p) => p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + } + } +} + +/// A switch or value of the Options card. +#[derive(Debug, Clone)] +pub enum OptionMsg { + InsertionPoint(bool), + Scale(bool), + Uniform(bool), + X(String), + Y(String), + Z(String), + Rotation(bool), + Angle(String), + AutoPlacement(bool), + Repeat(bool), + Explode(bool), +} + +/// The block context menu's commands. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MenuAction { + Insert, + Redefine, + Favorite, + Unfavorite, + Edit, + Remove, +} + /// Every message the block palette emits. Wrapped in `Message::BlockPalette`. #[derive(Debug, Clone)] pub enum BlockPaletteMsg { Search(String), - CyclePreviewSize, + Tab(Tab), + View(ViewMode), PickFile, - Insert(String), + FilePicked(Result), Refresh, - FilePicked(Result), + Option(OptionMsg), + ToggleOptions, + LibraryBrowse, + LibraryPicked(Result), + LibrarySelect(String), + LibraryUp, + /// Pressed on an item (a click when released on it, else a drag). + Press(Item), + Release(Item), + Menu(Item, MenuAction), } -/// Thumbnail box sizes for the three preview-size settings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PreviewSize { - #[default] - Small, - Medium, - Large, -} - -impl PreviewSize { - /// Number of cards per grid row. - fn columns(self) -> usize { - match self { - PreviewSize::Small => 3, - PreviewSize::Medium => 2, - PreviewSize::Large => 1, - } - } - /// Height (px) of the thumbnail box inside each card. - fn box_height(self) -> f32 { - match self { - PreviewSize::Small => 56.0, - PreviewSize::Medium => 84.0, - PreviewSize::Large => 120.0, - } - } -} - -/// Advance to the next preview size (Small → Medium → Large → Small). -pub fn cycle_preview_size(s: PreviewSize) -> PreviewSize { - match s { - PreviewSize::Small => PreviewSize::Medium, - PreviewSize::Medium => PreviewSize::Large, - PreviewSize::Large => PreviewSize::Small, - } -} - -/// One block's cached preview wires. +/// One block of the current drawing with its cached preview. pub struct BlockEntry { pub name: String, pub wires: Vec, + pub annotative: bool, + pub dynamic: bool, +} + +/// A library folder entry. +pub struct LibraryEntry { + pub path: PathBuf, + pub folder: bool, + pub thumb: Option, } /// Panel state held on the app. #[derive(Default)] pub struct BlockPalette { + pub tab: Tab, pub search: String, - pub preview_size: PreviewSize, - /// Cached (name, wires) pairs, rebuilt on refresh. + pub view: ViewMode, + /// Current drawing blocks, rebuilt on refresh. pub blocks: Vec, - /// Block name currently being placed (INSERT active), for the highlight. + /// Block name currently being placed, for the highlight. pub placing: Option, - /// Last known block-name list, for the cheap stale check. pub cached_names: Vec, - /// Document tab and block-definition revision that produced `blocks`. - /// Names alone are not enough: separate drawings can share block names, - /// and a block definition can change without being renamed. pub source_tab_id: Option, pub source_block_epoch: u64, + pub recent: Vec, + pub favorites: Vec, + /// Library roots, last used first. + pub libraries: Vec, + /// The folder shown (a library root or one of its sub-folders). + pub library_dir: Option, + pub library_entries: Vec, + /// Thumbnails of drawings recent / favorite entries point at. + pub file_thumbs: std::collections::HashMap>, + pub options: BlockInsertOptions, + /// The typed X / Y / Z / angle fields. + pub option_text: [String; 4], + pub options_collapsed: bool, + /// The item a press started on (a drag until released on it). + pub pressed: Option, + /// Blocks brought in from drawings this session (upper name → file), + /// so the Recent list remembers the drawing. + pub file_sources: std::collections::HashMap, + /// Previews of blocks seen this session, keyed `source|NAME`, for + /// recent / favorite blocks of other drawings. + pub ref_wires: std::collections::HashMap>, +} + +impl BlockPalette { + pub fn set_options(&mut self, options: BlockInsertOptions) { + self.option_text = [ + short(options.x), + short(options.y), + short(options.z), + short(options.angle), + ]; + self.options = options; + } + + /// The items of the current tab that pass the filter. + pub fn items(&self) -> Vec { + let pattern = self.search.trim(); + let pass = |name: &str| { + pattern.is_empty() || crate::io::xref_model::wildcard_match(name, pattern) + }; + match self.tab { + Tab::Current => self + .blocks + .iter() + .filter(|b| pass(&b.name)) + .map(|b| Item::Current(b.name.clone())) + .collect(), + Tab::Recent => self + .recent + .iter() + .filter(|r| pass(&r.name)) + .map(|r| Item::Ref(r.clone())) + .collect(), + Tab::Favorites => self + .favorites + .iter() + .filter(|r| pass(&r.name)) + .map(|r| Item::Ref(r.clone())) + .collect(), + Tab::Libraries => self + .library_entries + .iter() + .filter(|e| e.folder || pass(&display_name(&e.path))) + .map(|e| if e.folder { Item::Folder(e.path.clone()) } else { Item::File(e.path.clone()) }) + .collect(), + } + } + + fn current_entry(&self, name: &str) -> Option<&BlockEntry> { + self.blocks.iter().find(|b| b.name.eq_ignore_ascii_case(name)) + } +} + +pub fn short(v: f64) -> String { + let t = format!("{v:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn display_name(path: &std::path::Path) -> String { + path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() } /// Canvas program that draws a block's wires fit-to-box inside its bounds. @@ -106,7 +307,7 @@ impl<'a> Program for BlockPreviewCanvas<'a> { _cursor: iced::mouse::Cursor, ) -> Vec { let mut frame = Frame::new(renderer, bounds.size()); - let pad = 6.0; + let pad = 4.0; let inner_w = (bounds.width - 2.0 * pad).max(1.0); let inner_h = (bounds.height - 2.0 * pad).max(1.0); let (mut minx, mut miny, mut maxx, mut maxy) = @@ -124,7 +325,6 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } } if !any { - // Placeholder box so an empty block still reads as a card. let rect = Path::rectangle(iced::Point::new(pad, pad), iced::Size::new(inner_w, inner_h)); frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }); return vec![frame.into_geometry()]; @@ -134,9 +334,7 @@ impl<'a> Program for BlockPreviewCanvas<'a> { let scale = (inner_w / span_x).min(inner_h / span_y); let ox = (bounds.width - span_x * scale) * 0.5; let oy = (bounds.height - span_y * scale) * 0.5; - // World up → screen down; centre the fitted AABB. let map = |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale); - // Solid fills first so strokes sit on top. for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 0.55 }; for tri in w.fill_tris.chunks(3) { @@ -151,12 +349,11 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } } } - // Polylines, split into runs on NaN separators (NaN = polyline break). for w in self.wires { let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 1.0 }; let width = if w.line_weight_px > 1.5 { 2.0 } else { 1.0 }; let mut run: Vec = Vec::new(); - let flush = |frame: &mut Frame, run: &mut Vec, col: Color, width: f32| { + let flush = |frame: &mut Frame, run: &mut Vec| { if run.len() >= 2 { let path = Path::new(|p| { p.move_to(run[0]); @@ -172,112 +369,501 @@ impl<'a> Program for BlockPreviewCanvas<'a> { if p[0].is_finite() && p[1].is_finite() { run.push(map(p[0], p[1])); } else { - flush(&mut frame, &mut run, col, width); + flush(&mut frame, &mut run); } } - flush(&mut frame, &mut run, col, width); + flush(&mut frame, &mut run); } vec![frame.into_geometry()] } } +fn pm(m: BlockPaletteMsg) -> Message { + Message::BlockPalette(m) +} + +/// The thumbnail of an item, `size` pixels tall. +fn thumb<'a>(palette: &'a BlockPalette, item: &Item, size: f32) -> Element<'a, Message> { + let blank = || -> Element<'a, Message> { + container(Space::new()) + .width(Fill) + .height(Length::Fixed(size)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.10))), + ..Default::default() + }) + .into() + }; + let wires = |name: &str| palette.current_entry(name).map(|e| &e.wires[..]); + let file_thumb = |path: &str| palette.file_thumbs.get(path).cloned().flatten(); + match item { + Item::Current(name) => match wires(name) { + Some(w) => canvas(BlockPreviewCanvas { wires: w }).width(Fill).height(Length::Fixed(size)).into(), + None => blank(), + }, + Item::Ref(r) => { + if !r.drawing { + let seen = palette.ref_wires.get(&format!("{}|{}", r.source, r.name.to_ascii_uppercase())); + if let Some(w) = seen.map(|w| &w[..]).or_else(|| wires(&r.name)) { + return canvas(BlockPreviewCanvas { wires: w }).width(Fill).height(Length::Fixed(size)).into(); + } + } + match file_thumb(&r.source) { + Some(h) if r.drawing => image(h).width(Fill).height(Length::Fixed(size)).into(), + _ => blank(), + } + } + Item::File(path) => { + match palette.library_entries.iter().find(|e| &e.path == path).and_then(|e| e.thumb.clone()) { + Some(h) => image(h).width(Fill).height(Length::Fixed(size)).into(), + None => blank(), + } + } + Item::Folder(_) => container(crate::ui::icons::themed_secondary(crate::ui::icons::FOLDER_OPEN, size * 0.6)) + .center_x(Fill) + .center_y(Length::Fixed(size)) + .into(), + } +} + +/// The context menu for an item. +fn item_menu(tab: Tab, item: Item, in_drawing: bool, external: bool) -> Element<'static, Message> { + let entry = |label: String, action: MenuAction, enabled: bool| -> Element<'static, Message> { + let b = button(text(label).size(12)) + .width(Fill) + .padding([5, 12]) + .style(|theme: &Theme, status| { + let mut s = button::text(theme, status); + if matches!(status, button::Status::Hovered) { + s.background = Some(Background::Color(theme.palette().primary.base.color)); + s.text_color = theme.palette().primary.base.text; + } + s + }); + if enabled { + b.on_press(pm(BlockPaletteMsg::Menu(item.clone(), action))).into() + } else { + b.into() + } + }; + let favorite = if tab == Tab::Favorites { + entry(crate::t!("Remove from Favorites").into_owned(), MenuAction::Unfavorite, true) + } else { + entry(crate::t!("Add to Favorites").into_owned(), MenuAction::Favorite, !matches!(item, Item::Folder(_))) + }; + let remove_label = match tab { + Tab::Recent | Tab::Favorites => crate::t!("Remove from List"), + _ => crate::t!("Remove Block"), + }; + let remove_enabled = match tab { + Tab::Current => true, + Tab::Recent | Tab::Favorites => true, + Tab::Libraries => false, + }; + container( + column![ + entry(crate::t!("Insert").into_owned(), MenuAction::Insert, !matches!(item, Item::Folder(_))), + entry(crate::t!("Redefine").into_owned(), MenuAction::Redefine, external), + favorite, + container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }), + entry(crate::t!("Edit Block").into_owned(), MenuAction::Edit, in_drawing), + entry(remove_label.into_owned(), MenuAction::Remove, remove_enabled), + ] + .spacing(1), + ) + .padding(4) + .width(Length::Fixed(200.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// One item as a card / row, with press, release and the context menu. +fn item_view<'a>(palette: &'a BlockPalette, item: Item) -> Element<'a, Message> { + let name = item.name(); + let placing = matches!(&item, Item::Current(n) if palette.placing.as_deref() == Some(n.as_str())); + let in_drawing = palette.current_entry(&name).is_some() + && !matches!(&item, Item::Ref(r) if r.drawing) + && !matches!(item, Item::File(_) | Item::Folder(_)); + let external = matches!(&item, Item::Ref(r) if !r.source.is_empty()) || matches!(item, Item::File(_)); + let body: Element<'a, Message> = match palette.view.grid() { + Some((_, size)) => column![ + thumb(palette, &item, size), + text(crate::ui::text_util::elide(&name, 14)) + .size(11) + .width(Fill) + .height(Length::Fixed(LABEL_LINE_H)) + .center(), + ] + .spacing(4) + .into(), + None => match palette.view { + ViewMode::Small => row![ + container(thumb(palette, &item, 16.0)).width(Length::Fixed(20.0)), + text(crate::ui::text_util::elide(&name, 16)).size(11), + ] + .spacing(4) + .align_y(iced::Center) + .into(), + ViewMode::Details => { + let (ann, dynamic) = palette + .current_entry(&name) + .map_or((false, false), |e| (e.annotative, e.dynamic)); + let yes_no = |v: bool| if v { crate::t!("Yes") } else { crate::t!("No") }.into_owned(); + row![ + container(thumb(palette, &item, 16.0)).width(Length::Fixed(40.0)), + text(name.clone()).size(11).width(Fill), + text(format!("{} / {}", yes_no(ann), yes_no(dynamic))) + .size(11) + .width(Length::Fixed(140.0)), + ] + .spacing(4) + .align_y(iced::Center) + .into() + } + _ => text(name.clone()).size(11).width(Fill).into(), + }, + }; + let card = container(body) + .padding(if palette.view.grid().is_some() { 6 } else { 3 }) + .width(Fill) + .style(move |theme: &Theme| { + let (bg, fg) = block_card_colors(theme, placing, button::Status::Active); + container::Style { + background: Some(Background::Color(bg)), + text_color: Some(fg), + border: Border { + color: block_card_border(theme, placing), + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }); + let area = mouse_area(card) + .on_press(pm(BlockPaletteMsg::Press(item.clone()))) + .on_release(pm(BlockPaletteMsg::Release(item.clone()))) + .interaction(iced::mouse::Interaction::Pointer); + let tab = palette.tab; + iced_aw::ContextMenu::new(area, move || item_menu(tab, item.clone(), in_drawing, external)).into() +} + +fn icon_button<'a>(icon: IconKind, tip: &str, msg: BlockPaletteMsg) -> Element<'a, Message> { + let icon_el: Element<'_, Message> = match icon { + IconKind::Glyph(s) => text(s).size(15).into(), + IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), + }; + crate::ui::dock::tool_button(icon_el, crate::t!(tip).into_owned(), pm(msg)) +} + +fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> { + let o = &palette.options; + let check = |label: String, on: bool, f: fn(bool) -> OptionMsg| -> Element<'a, Message> { + checkbox(on) + .label(label) + .text_size(12) + .size(14) + .on_toggle(move |v| pm(BlockPaletteMsg::Option(f(v)))) + .into() + }; + let num = |value: &'a str, f: fn(String) -> OptionMsg, enabled: bool| -> Element<'a, Message> { + let mut input = text_input("", value).size(11).padding([3, 6]).width(Length::Fixed(48.0)).style(field_style); + if enabled { + input = input.on_input(move |v| pm(BlockPaletteMsg::Option(f(v)))); + } + input.into() + }; + let header = mouse_area( + row![ + text(crate::t!("Options").to_uppercase()).size(10).style(accent_text), + Space::new().width(Fill), + text(if palette.options_collapsed { "▸" } else { "▾" }).size(11), + ] + .align_y(iced::Center), + ) + .on_press(pm(BlockPaletteMsg::ToggleOptions)) + .interaction(iced::mouse::Interaction::Pointer); + let mut content = column![header].spacing(6); + if !palette.options_collapsed { + let scale_kinds = vec![crate::t!("Scale").into_owned(), crate::t!("Uniform Scale").into_owned()]; + let current_kind = scale_kinds[o.uniform as usize].clone(); + let uniform_label = scale_kinds[1].clone(); + let kind = pick_list(Some(current_kind), scale_kinds, |v: &String| v.clone()) + .on_select(move |v: String| pm(BlockPaletteMsg::Option(OptionMsg::Uniform(v == uniform_label)))) + .text_size(11) + .padding([3, 6]); + let fields_on = !o.scale; + let mut scale_row = row![ + checkbox(o.scale).size(14).on_toggle(|v| pm(BlockPaletteMsg::Option(OptionMsg::Scale(v)))), + kind, + ] + .spacing(6) + .align_y(iced::Center); + // The scale values sit on their own line under the switch. + let mut xyz_row = row![Space::new().width(Length::Fixed(20.0))].spacing(6).align_y(iced::Center); + if o.scale { + scale_row = scale_row.push(text(crate::t!("specified on screen")).size(11).style(muted_style)); + } else if o.uniform { + xyz_row = xyz_row.push(num(&palette.option_text[0], OptionMsg::X, fields_on)); + } else { + xyz_row = xyz_row + .push(text("X").size(11)) + .push(num(&palette.option_text[0], OptionMsg::X, fields_on)) + .push(text("Y").size(11)) + .push(num(&palette.option_text[1], OptionMsg::Y, fields_on)) + .push(text("Z").size(11)) + .push(num(&palette.option_text[2], OptionMsg::Z, fields_on)); + } + let mut rot_row = row![check(crate::t!("Rotation").into_owned(), o.rotation, OptionMsg::Rotation), Space::new().width(Fill)] + .spacing(6) + .align_y(iced::Center); + if o.rotation { + rot_row = rot_row.push(text(crate::t!("specified on screen")).size(11).style(muted_style)); + } else { + rot_row = rot_row + .push(num(&palette.option_text[3], OptionMsg::Angle, true)) + .push(text(crate::t!("Angle")).size(11)); + } + content = content + .push(check(crate::t!("Insertion Point").into_owned(), o.insertion_point, OptionMsg::InsertionPoint)) + .push(scale_row); + if !o.scale { + content = content.push(xyz_row); + } + content = content + .push(rot_row) + .push(check(crate::t!("Auto-Placement").into_owned(), o.auto_placement, OptionMsg::AutoPlacement)) + .push(check(crate::t!("Repeat Placement").into_owned(), o.repeat, OptionMsg::Repeat)) + .push(check(crate::t!("Explode").into_owned(), o.explode, OptionMsg::Explode)); + } + container(content).padding([8, 10]).width(Fill).style(card_style).into() +} + /// Build the docked panel element from the palette state. pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> { - let query = palette.search.trim().to_lowercase(); - let filtered: Vec<&BlockEntry> = palette - .blocks - .iter() - .filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query)) - .collect(); + let title_bar = crate::ui::dock::title_bar(PanelId::BlockPalette, crate::t!("Blocks").into_owned(), auto_collapse); - let title_bar = crate::ui::dock::title_bar( - PanelId::BlockPalette, - crate::t!("Block Palette").into_owned(), - auto_collapse, - ); + let tabs = { + let buttons = [Tab::Current, Tab::Recent, Tab::Favorites, Tab::Libraries].map(|t| { + button(text(t.label()).size(10).width(Fill).align_x(iced::Center)) + .on_press(pm(BlockPaletteMsg::Tab(t))) + .style(button_style(t == palette.tab)) + .padding([5, 1]) + .width(Fill) + .into() + }); + container(iced::widget::Row::with_children(buttons).spacing(2)) + .padding(2) + .width(Fill) + .style(well_style) + }; - let search_input = text_input(&crate::t!("Search blocks…"), &palette.search) - .on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v))) + let search_input = text_input(&crate::t!("Filter... (* ? wildcards)"), &palette.search) + .on_input(|v| pm(BlockPaletteMsg::Search(v))) .padding([4, 8]) - .size(12); - + .size(12) + .style(field_style); + let view_pick = pick_list(Some(palette.view.label()), ViewMode::ALL.map(|v| v.label()).to_vec(), |v: &String| v.clone()) + .on_select(|label: String| { + pm(BlockPaletteMsg::View( + ViewMode::ALL.into_iter().find(|v| v.label() == label).unwrap_or_default(), + )) + }) + .text_size(11) + .width(Length::Fixed(118.0)) + .padding([3, 6]); let header = row![ - search_input, + search_input.width(Fill), icon_button( IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")), - "Insert from file", + "Insert a drawing as a block", BlockPaletteMsg::PickFile, ), - icon_button( - IconKind::Svg(include_bytes!("../../../assets/icons/blocks/preview_size.svg")), - "Preview size", - BlockPaletteMsg::CyclePreviewSize, - ), + view_pick, ] .spacing(4) .align_y(iced::Center); - let body: Element<'_, Message> = if filtered.is_empty() { - let msg = if palette.cached_names.is_empty() { - "No blocks in this drawing" - } else { - "No matches" + let mut top = column![tabs, header].spacing(6); + if palette.tab == Tab::Libraries { + let names: Vec = palette.libraries.clone(); + let selected = palette.libraries.first().cloned(); + let mut lib_row = row![ + pick_list(selected, names, |v: &String| v.clone()) + .on_select(|v: String| pm(BlockPaletteMsg::LibrarySelect(v))) + .placeholder(crate::t!("No library").into_owned()) + .text_size(11) + .padding([3, 6]) + .width(Fill), + button(text("…").size(12)).on_press(pm(BlockPaletteMsg::LibraryBrowse)).style(button_style(false)).padding([3, 8]), + ] + .spacing(4) + .align_y(iced::Center); + let in_sub = palette.library_dir.as_ref().is_some_and(|d| { + palette.libraries.first().is_some_and(|root| d != &PathBuf::from(root)) + }); + if in_sub { + lib_row = lib_row.push(button(text("↑").size(12)).on_press(pm(BlockPaletteMsg::LibraryUp)).style(button_style(false)).padding([3, 8])); + } + top = top.push(lib_row); + if let (Some(root), Some(dir)) = (palette.libraries.first(), palette.library_dir.as_ref()) { + let root = PathBuf::from(root); + let shown = match dir.strip_prefix(root.parent().unwrap_or(&root)) { + Ok(rel) => rel.display().to_string(), + Err(_) => dir.display().to_string(), + }; + top = top.push(text(format!("{} {shown}", crate::t!("Path:"))).size(10).style(accent_text)); + } + } + top = top.push(text(palette.tab.heading().to_uppercase()).size(10).style(accent_text)); + + let items = palette.items(); + let body: Element<'_, Message> = if items.is_empty() { + let msg = match palette.tab { + Tab::Current if palette.cached_names.is_empty() => crate::t!("No blocks in this drawing"), + Tab::Libraries if palette.libraries.is_empty() => crate::t!("Choose a library folder with …"), + _ => crate::t!("No matches"), }; - container( - text(crate::t!(msg)) - .size(12) - .style(|theme: &Theme| iced::widget::text::Style { - color: Some( - theme - .palette() - .background - .base - .text - .scale_alpha(0.72), - ), - }), - ) - .center_x(Fill) - .center_y(Fill) + container(text(msg).size(12).style(muted_style)).center_x(Fill).center_y(Fill).width(Fill).height(Fill).into() + } else { + let mut col = column![].spacing(4); + match palette.view.grid() { + Some((per_row, _)) => { + for chunk in items.chunks(per_row) { + let mut r = row![].spacing(6).width(Fill); + for item in chunk { + r = r.push(item_view(palette, item.clone())); + } + for _ in chunk.len()..per_row { + r = r.push(Space::new().width(Fill)); + } + col = col.push(r); + } + } + None if palette.view == ViewMode::Small => { + for chunk in items.chunks(2) { + let mut r = row![].spacing(6).width(Fill); + for item in chunk { + r = r.push(item_view(palette, item.clone())); + } + if chunk.len() < 2 { + r = r.push(Space::new().width(Fill)); + } + col = col.push(r); + } + } + None => { + if palette.view == ViewMode::Details { + col = col.push( + row![ + text(crate::t!("Icon")).size(11).style(muted_style).width(Length::Fixed(40.0)), + text(format!("{} ▲", crate::t!("Name"))).size(11).style(muted_style).width(Fill), + text(crate::t!("Annotative / Dynamic")).size(11).style(muted_style).width(Length::Fixed(140.0)), + ] + .spacing(4), + ); + } + for item in items { + col = col.push(item_view(palette, item)); + } + } + } + scrollable(container(col).padding(iced::Padding { top: 0.0, right: 8.0, bottom: 6.0, left: 0.0 })) .width(Fill) .height(Fill) .into() - } else { - let mut col = column![].spacing(6); - for chunk in filtered.chunks(palette.preview_size.columns()) { - let mut r = row![].spacing(6).width(Fill); - for block in chunk { - r = r.push(block_card(palette, block)); - } - col = col.push(r); - } - scrollable( - container(col).padding(iced::Padding { - top: 0.0, - right: 8.0, - bottom: 6.0, - left: 0.0, - }), - ) - .width(Fill) - .height(Fill) - .into() }; - crate::ui::dock::frame(column![title_bar, header, body].spacing(6), width) + crate::ui::dock::frame(column![title_bar, top, body, options_card(palette)].spacing(6), width) } -/// Theme-aware foreground/background pair for a block card. -/// -/// Centralizes the card's text/background mapping so the contrast suite can -/// exercise the exact colors the UI renders (instead of re-implementing the -/// match). Normal cards use the `base` pair, hovered/pressed cards use the -/// `strong` pair, and the actively-placing card uses the `primary.base` pair. -pub(crate) fn block_card_colors( - theme: &Theme, - is_placing: bool, - status: button::Status, -) -> (Color, Color) { +/// Width of the ribbon's block gallery. +pub const GALLERY_W: f32 = 300.0; + +/// The ribbon Insert gallery: the drawing's blocks (a click inserts one), +/// then the ways into the palette's other tabs. +pub fn gallery(palette: &BlockPalette) -> Element<'_, Message> { + let ribbon_command = |cmd: String| Message::RibbonToolClick { + tool_id: "INSERT".to_string(), + event: crate::modules::ModuleEvent::Command(cmd), + }; + let mut grid = column![].spacing(4); + for chunk in palette.blocks.chunks(4) { + let mut r = row![].spacing(4).width(Fill); + for block in chunk { + let card = column![ + canvas(BlockPreviewCanvas { wires: &block.wires }).width(Fill).height(Length::Fixed(48.0)), + text(crate::ui::text_util::elide(&block.name, 10)) + .size(10) + .width(Fill) + .height(Length::Fixed(LABEL_LINE_H)) + .center(), + ] + .spacing(2); + r = r.push( + button(card) + .on_press(ribbon_command(format!("_BLOCKINSERT {}", block.name))) + .style(crate::ui::ribbon::widgets::popup_row_style) + .padding(3) + .width(Fill), + ); + } + for _ in chunk.len()..4 { + r = r.push(Space::new().width(Fill)); + } + grid = grid.push(r); + } + let blocks: Element<'_, Message> = if palette.blocks.is_empty() { + container(text(crate::t!("No blocks in this drawing")).size(11).style(muted_style)) + .padding(10) + .center_x(Fill) + .into() + } else { + scrollable(container(grid).padding(iced::Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 0.0 })) + .height(Length::Shrink) + .into() + }; + let link = |label: std::borrow::Cow<'static, str>, tab: &str| -> Element<'_, Message> { + button(text(label.into_owned()).size(11)) + .on_press(ribbon_command(format!("_BLOCKSPALETTE {tab}"))) + .style(crate::ui::ribbon::widgets::popup_row_style) + .width(Fill) + .padding([5, 10]) + .into() + }; + container( + column![ + text(crate::t!("Current Drawing Blocks").to_uppercase()).size(10).style(accent_text), + container(blocks).height(Length::Fixed(((palette.blocks.len().div_ceil(4)) as f32 * 79.0).clamp(36.0, 260.0))), + container(Space::new()).height(1).width(Fill).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }), + link(crate::t!("Recent Blocks..."), "RECENT"), + link(crate::t!("Favorite Blocks..."), "FAVORITES"), + link(crate::t!("Blocks from Libraries..."), "LIBRARIES"), + ] + .spacing(6), + ) + .padding(8) + .width(Length::Fixed(GALLERY_W)) + .style(crate::ui::ribbon::widgets::popup_panel_style) + .into() +} + +/// Theme-aware foreground/background pair for a block card (normal, +/// hovered/pressed, or the block being placed). +pub(crate) fn block_card_colors(theme: &Theme, is_placing: bool, status: button::Status) -> (Color, Color) { let palette = theme.palette(); if is_placing { (palette.primary.base.color, palette.primary.base.text) @@ -286,15 +872,12 @@ pub(crate) fn block_card_colors( button::Status::Hovered | button::Status::Pressed => { (palette.background.strong.color, palette.background.strong.text) } - _ => ( - palette.background.base.color, - palette.background.base.text, - ), + _ => (palette.background.weak.color, palette.background.weak.text), } } } -/// Border color for a block card in a given state (exposed for contrast tests). +/// Border color for a block card in a given state. pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { let palette = theme.palette(); if is_placing { @@ -304,11 +887,8 @@ pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { } } -/// Theme-aware foreground for the palette's header icon buttons. -/// -/// Transparent resting state composites over the dock (`base`) background; -/// hovered/pressed state sits on the `strong` surface, so each state pairs -/// with its own background's text color to preserve contrast. +/// Theme-aware foreground for the palette's header icon buttons (shared with +/// the dock's tool buttons). pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status) -> Color { let palette = theme.palette(); match status { @@ -316,64 +896,3 @@ pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status _ => palette.background.base.text, } } - -fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { - let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); - let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) - .width(Fill) - .height(Length::Fixed(palette.preview_size.box_height())); - // Fixed height guarantees the label occupies exactly one line, so every - // cell in a row keeps the same height even for long names (the elide budget - // handles overflow, and this clips anything that still won't fit). - // - // NOTE: no explicit `.color(...)` here — the label inherits the button's - // `text_color` so normal/hover/placing states each get a theme-aware - // foreground with sufficient contrast on both light and dark themes. - // (A hardcoded `Color::WHITE` is unreadable on light themes.) - let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) - .size(11) - .width(Fill) - .height(Length::Fixed(LABEL_LINE_H)) - .center(); - let content = column![preview, label].spacing(4).padding(6); - button(content) - .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) - .width(Fill) - .style(move |theme: &Theme, status| { - let (bg, fg) = block_card_colors(theme, is_placing, status); - let border = block_card_border(theme, is_placing); - button::Style { - background: Some(Background::Color(bg)), - border: Border { - color: border, - width: 1.0, - radius: 3.0.into(), - }, - text_color: fg, - ..Default::default() - } - }) - .into() -} - -fn icon_button<'a>(icon: IconKind, tip: &str, msg: BlockPaletteMsg) -> Element<'a, Message> { - let icon_el: Element<'_, Message> = match icon { - // No explicit color — inherits the button's theme-aware `text_color` - // so glyphs stay legible on light themes. - IconKind::Glyph(s) => text(s).size(15).into(), - IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), - }; - crate::ui::dock::tool_button(icon_el, crate::t!(tip).into_owned(), Message::BlockPalette(msg)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn preview_size_cycles() { - assert_eq!(cycle_preview_size(PreviewSize::Small), PreviewSize::Medium); - assert_eq!(cycle_preview_size(PreviewSize::Medium), PreviewSize::Large); - assert_eq!(cycle_preview_size(PreviewSize::Large), PreviewSize::Small); - } -} diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index a59d918b1..e2cc8e4f9 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -233,13 +233,13 @@ pub fn click_page(selected: &mut Vec, anchor: &mut usize, page: usize, ct // accent-coloured card titles, segmented choices and toggle chips, with the // help button on the left of the footer and the named action on the right. -fn accent_text(theme: &Theme) -> text::Style { +pub(crate) fn accent_text(theme: &Theme) -> text::Style { text::Style { color: Some(theme.palette().primary.base.color), } } -fn card_style(theme: &Theme) -> container::Style { +pub(crate) fn card_style(theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), border: Border { @@ -251,7 +251,7 @@ fn card_style(theme: &Theme) -> container::Style { } } -fn well_style(theme: &Theme) -> container::Style { +pub(crate) fn well_style(theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(theme.palette().background.base.color)), border: Border {