From 4726c775bf01d38a6ffddc5d3271cee4ef148de1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:10:54 +0300 Subject: [PATCH 1/2] Block Attribute Manager window (BATTMAN / ATTMAN) BATTMAN and ATTMAN open a manager for the attribute definitions of one attributed block: block chips with reference counts and a pick button, a table whose columns are chosen with chips, move up/down, remove, and an edit card (Attribute / Text / Properties) for the selected row. Apply, OK and Sync write the list back to the block definition and, when asked, rebuild the existing references keeping values through tag renames; each is one undo step. Live changes are taken back on Cancel. Automation gains attman_dialog. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 14 + locales/bg-BG/opencadstudio.ftl | 14 + locales/cs-CZ/opencadstudio.ftl | 14 + locales/de-DE/opencadstudio.ftl | 14 + locales/el-GR/opencadstudio.ftl | 14 + locales/en-US/opencadstudio.ftl | 14 + locales/es-ES/opencadstudio.ftl | 14 + locales/fi-FI/opencadstudio.ftl | 14 + locales/fr-FR/opencadstudio.ftl | 14 + locales/hi-IN/opencadstudio.ftl | 14 + locales/hu-HU/opencadstudio.ftl | 14 + locales/it-IT/opencadstudio.ftl | 14 + locales/ja-JP/opencadstudio.ftl | 14 + locales/ko-KR/opencadstudio.ftl | 14 + locales/lo-LA/opencadstudio.ftl | 14 + locales/nl-NL/opencadstudio.ftl | 14 + locales/pl-PL/opencadstudio.ftl | 14 + locales/pt-BR/opencadstudio.ftl | 14 + locales/ru-RU/opencadstudio.ftl | 14 + locales/tr-TR/opencadstudio.ftl | 14 + locales/zh-CN/opencadstudio.ftl | 14 + locales/zh-TW/opencadstudio.ftl | 14 + src/app/commands/attman.rs | 548 +++++++++++++++ src/app/commands/block_cmdline.rs | 16 +- src/app/commands/blocks.rs | 6 +- src/app/commands/mod.rs | 1 + src/app/control/actions.rs | 94 +++ src/app/mod.rs | 7 + src/app/update/mod.rs | 9 + src/app/view/modal.rs | 7 + src/locale_catalog.rs | 12 + src/modules/insert/attman.rs | 41 ++ src/modules/insert/mod.rs | 2 +- src/ui/window/attman_dialog.rs | 1037 +++++++++++++++++++++++++++++ src/ui/window/mod.rs | 1 + 35 files changed, 2083 insertions(+), 6 deletions(-) create mode 100644 src/app/commands/attman.rs create mode 100644 src/ui/window/attman_dialog.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 83175dbf1..56f74d30a 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6609,3 +6609,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index cc20285c3..cfa9ca912 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6597,3 +6597,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 66afe10f6..506042abb 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6594,3 +6594,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 61eef3101..2a0d73593 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6595,3 +6595,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 07d706269..17e21790a 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6597,3 +6597,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index e241beee5..2e90f5aa9 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6596,3 +6596,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 9a4f1c86b..d2f14d030 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6597,3 +6597,17 @@ ucs-follow = .prompt = UCSFOLLOW: introduce 0 (conservar vista) o 1 (seguir SCP) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activa una ventana de modelo; no se aplica al espacio papel. .invalid = UCSFOLLOW: solo se permite 0 o 1. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 0ec188498..048c40d76 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6594,3 +6594,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index ae9d437ba..d401e3114 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6597,3 +6597,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index db5a95696..3f26684e8 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6585,3 +6585,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a31cd56a4..b76bbcf58 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6581,3 +6581,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 1d1563b99..279ed1f1d 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6596,3 +6596,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 26de504d7..7bf028d9c 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6585,3 +6585,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c43410f04..67b1695d3 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6596,3 +6596,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 8f51fc302..371423d9a 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6597,3 +6597,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 761118ba4..e5109a004 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6595,3 +6595,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 16d72a254..0757a6921 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6596,3 +6596,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 3ffec2a7b..c50da078b 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6597,3 +6597,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 3840440f1..40cd7df64 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6598,3 +6598,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2733014ca..ae4416c3c 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6518,3 +6518,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Öznitelik Yöneticisi + .modes = Kipler + .references = __ocs_fmt_0__ referans + .counts = Çizimde __ocs_fmt_0__ · model alanında __ocs_fmt_1__ + .columns = Sütunlar: + .constant-note = Sabit kipi tanımda değiştirilemez. + .live = Değişiklikleri çizimde canlı göster + .apply-existing = Mevcut referanslara uygula + .emphasize = Yinelenen etiketleri vurgula + .sync = Eşitle + .help = BATTMAN bir bloğun öznitelik tanımlarını yönetir. + .no-attributed-block = Blok tablosunda __ocs_fmt_0__ adlı öznitelikli blok yok. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 81edca569..59547a326 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6580,3 +6580,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index b7941d3ed..bb5da1925 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6592,3 +6592,17 @@ ucs-follow = .prompt = UCSFOLLOW: enter 0 (keep view) or 1 (follow UCS) <__ocs_fmt_0__>: .paper-space = UCSFOLLOW: activate a model viewport; paper space cannot follow the UCS. .invalid = UCSFOLLOW: only 0 or 1 is allowed. + +attman = + .title = Block Attribute Manager + .modes = Modes + .references = __ocs_fmt_0__ references + .counts = In drawing: __ocs_fmt_0__ · in model space: __ocs_fmt_1__ + .columns = Columns: + .constant-note = The Constant mode cannot be changed in the definition. + .live = Show changes in the drawing as they are made + .apply-existing = Apply changes to existing references + .emphasize = Emphasize duplicate tags + .sync = Sync + .help = BATTMAN manages the attribute definitions of a block. + .no-attributed-block = Block table contains no attributed block named __ocs_fmt_0__. diff --git a/src/app/commands/attman.rs b/src/app/commands/attman.rs new file mode 100644 index 000000000..f3bac6acf --- /dev/null +++ b/src/app/commands/attman.rs @@ -0,0 +1,548 @@ +//! Block Attribute Manager (BATTMAN / ATTMAN): loading an attributed block's +//! definitions into the dialog and writing the edited list back to the +//! block definition and, when asked, to its existing references. + +use codec::entities::AttributeDefinition; +use codec::{EntityType, Handle}; +use iced::Task; + +use crate::app::{Message, OpenCADStudio}; +use crate::modules::insert::block_cmdline::BlockTable; +use crate::scene::view::dispatch::{apply_common_prop, apply_geom_prop}; +use crate::ui::window::attman_dialog::{ + self as dialog, AttManBlock, AttManMsg, AttManRow, AttManState, EditTab, +}; + +impl OpenCADStudio { + /// Open the dialog on the block of the first preselected attributed + /// reference, else on the first attributed block. + pub(in crate::app) fn open_attman(&mut self, i: usize, preselected: &[Handle]) -> Task { + let document = &self.tabs[i].scene.document; + let table = BlockTable::of(document); + let model = document + .block_records + .get("*Model_Space") + .map_or(Handle::NULL, |br| br.handle); + let blocks: Vec = table + .attributed + .iter() + .map(|name| { + let mut found = 0; + let mut in_space = 0; + for entity in document.entities() { + if let EntityType::Insert(insert) = entity { + if insert.block_name.eq_ignore_ascii_case(name) { + found += 1; + if insert.common.owner_handle == model { + in_space += 1; + } + } + } + } + AttManBlock { + name: name.clone(), + found, + in_space, + } + }) + .collect(); + let start = preselected + .iter() + .find_map(|h| match document.get_entity(*h) { + Some(EntityType::Insert(insert)) => blocks + .iter() + .position(|b| b.name.eq_ignore_ascii_case(&insert.block_name)), + _ => None, + }) + .unwrap_or(0); + let mut styles: Vec = document + .text_styles + .iter() + .filter(|s| !s.name.is_empty()) + .map(|s| s.name.clone()) + .collect(); + styles.sort_by_key(|s| s.to_lowercase()); + let layers: Vec = document.layers.iter().map(|l| l.name.clone()).collect(); + let linetypes: Vec = document + .line_types + .iter() + .map(|lt| lt.name.clone()) + .filter(|n| !n.eq_ignore_ascii_case("ByLayer") && !n.eq_ignore_ascii_case("ByBlock")) + .collect(); + self.attman = Some(AttManState { + blocks, + block: start, + rows: Vec::new(), + removed: Vec::new(), + selected: None, + tab: EditTab::Attribute, + edit_open: true, + drafts: Default::default(), + dirty: false, + live_steps: 0, + more_columns: false, + styles, + layers, + linetypes, + error: None, + }); + self.attman_load(i); + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::AttMan); + Task::none() + } + + /// Read the shown block's attribute definitions, in block order. + fn attman_load(&mut self, i: usize) { + let document = &self.tabs[i].scene.document; + let Some(state) = self.attman.as_mut() else { + return; + }; + let Some(name) = state.current_block().map(|b| b.name.clone()) else { + return; + }; + state.rows = document + .block_records + .get(&name) + .map(|br| { + br.entity_handles + .iter() + .filter_map(|h| match document.get_entity(*h) { + Some(EntityType::AttributeDefinition(def)) => Some(AttManRow { + handle: *h, + original_tag: def.tag.clone(), + def: def.clone(), + }), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + state.removed.clear(); + state.dirty = false; + state.selected = (!state.rows.is_empty()).then_some(0); + state.load_drafts(); + } + + pub(in crate::app) fn on_attman(&mut self, m: AttManMsg) -> Task { + let i = self.active_tab; + let Some(state) = self.attman.as_mut() else { + return Task::none(); + }; + // Edits of the selected row. + let mut edited = true; + let at = state.selected; + match m { + AttManMsg::Block(index) => { + if index == state.block || index >= state.blocks.len() { + return Task::none(); + } + // Pending changes of the shown block are kept. + if state.dirty && !self.attman_commit(i, true, false) { + return Task::none(); + } + if let Some(state) = self.attman.as_mut() { + state.block = index; + } + self.attman_load(i); + return Task::none(); + } + AttManMsg::PickBlock => { + self.active_modal = None; + let command = crate::modules::insert::attman::AttmanPickCommand; + use crate::command::CadCommand as _; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Task::none(); + } + AttManMsg::Select(index) => { + if index < state.rows.len() { + state.selected = Some(index); + state.load_drafts(); + } + return Task::none(); + } + AttManMsg::MoveUp | AttManMsg::MoveDown => { + let Some(at) = at else { + return Task::none(); + }; + let to = if matches!(m, AttManMsg::MoveUp) { + at.checked_sub(1) + } else { + Some(at + 1).filter(|t| *t < state.rows.len()) + }; + let Some(to) = to else { + return Task::none(); + }; + state.rows.swap(at, to); + state.selected = Some(to); + } + AttManMsg::Remove => { + let Some(at) = at else { + return Task::none(); + }; + if state.rows.len() < 2 { + return Task::none(); + } + let row = state.rows.remove(at); + state.removed.push(row.handle); + state.selected = Some(at.min(state.rows.len() - 1)); + state.load_drafts(); + } + AttManMsg::ToggleEdit => { + state.edit_open = !state.edit_open; + return Task::none(); + } + AttManMsg::Tab(tab) => { + state.tab = tab; + return Task::none(); + } + AttManMsg::MoreColumns => { + state.more_columns = !state.more_columns; + return Task::none(); + } + AttManMsg::Column(column, on) => { + let bit = 1u32 << (column as u32); + let mut prefs = dialog::prefs(); + prefs.columns = if on { + prefs.columns | bit + } else { + prefs.columns & !bit + }; + return Task::none(); + } + AttManMsg::ApplyExisting(on) => { + dialog::prefs().apply_existing = on; + return Task::none(); + } + AttManMsg::Emphasize(on) => { + dialog::prefs().emphasize_duplicates = on; + return Task::none(); + } + AttManMsg::Live(on) => { + dialog::prefs().live = on; + if on && state.dirty { + self.attman_commit(i, false, false); + } + return Task::none(); + } + AttManMsg::DismissError => { + state.error = None; + return Task::none(); + } + AttManMsg::Help => { + self.command_line.push_info( + crate::t!("BATTMAN manages the attribute definitions of a block.").as_ref(), + ); + return Task::none(); + } + AttManMsg::Sync => { + self.attman_commit(i, true, true); + return Task::none(); + } + AttManMsg::Apply => { + self.attman_commit(i, true, false); + return Task::none(); + } + AttManMsg::Ok => { + if self.attman_commit(i, true, false) { + self.attman = None; + self.close_active_modal(); + } + return Task::none(); + } + AttManMsg::Cancel => { + let steps = state.live_steps; + self.attman = None; + self.close_active_modal(); + if steps > 0 { + self.undo_steps(steps); + } + return Task::none(); + } + _ => { + edited = false; + } + } + if edited { + state.dirty = true; + return self.attman_after_edit(i); + } + // Field edits of the selected row. + let Some(row) = at.and_then(|at| state.rows.get_mut(at)) else { + return Task::none(); + }; + let def = &mut row.def; + let yes = |on: bool| if on { "Yes" } else { "No" }; + match m { + AttManMsg::Tag(value) => { + state.drafts.tag = value.clone(); + if crate::ui::window::attdef_dialog::tag_error(&value).is_none() { + geom(def, "att_tag", &value); + } + } + AttManMsg::Prompt(value) => geom(def, "att_prompt", &value), + AttManMsg::Default(value) => geom(def, "att_default", &value), + AttManMsg::Mode(key, on) => { + let field = match key { + "invisible" => "att_invisible", + "verify" => "att_verify", + "preset" => "att_preset", + "multiline" => "att_multiline", + _ => return Task::none(), + }; + geom(def, field, yes(on)); + } + AttManMsg::Style(value) => geom(def, "att_style", &value), + AttManMsg::Justify(justify) => geom(def, "att_justify", justify.label()), + AttManMsg::Height(value) => { + state.drafts.height = value.clone(); + geom(def, "att_h", &value); + } + AttManMsg::Rotation(value) => { + state.drafts.rotation = value.clone(); + geom(def, "att_rot", &value); + } + AttManMsg::Width(value) => { + state.drafts.width = value.clone(); + geom(def, "att_wf", &value); + } + AttManMsg::Oblique(value) => { + state.drafts.oblique = value.clone(); + geom(def, "att_ob", &value); + } + AttManMsg::Boundary(value) => { + state.drafts.boundary = value.clone(); + geom(def, "att_bwidth", &value); + } + AttManMsg::Annotative(on) => geom(def, "att_annotative", yes(on)), + AttManMsg::Backwards(on) => geom(def, "att_backward", yes(on)), + AttManMsg::UpsideDown(on) => geom(def, "att_upside_down", yes(on)), + AttManMsg::Layer(value) => common(def, "layer", &value), + AttManMsg::Linetype(value) => common(def, "linetype", &value), + AttManMsg::Color(value) => { + if let Some(color) = dialog::parse_color(&value) { + def.common.color = color; + } + } + AttManMsg::Lineweight(value) => { + if let Some(lw) = dialog::parse_lineweight(&value) { + def.common.line_weight = lw; + } + } + _ => return Task::none(), + } + state.dirty = true; + self.attman_after_edit(i) + } + + fn attman_after_edit(&mut self, i: usize) -> Task { + if dialog::prefs().live { + self.attman_commit(i, false, false); + } + Task::none() + } + + /// The pick of `_ATTMAN_PICK `: show the picked reference's + /// block. A reference of a block without attributes is reported. + pub(in crate::app) fn attman_picked(&mut self, i: usize, handle: &str) -> Task { + self.active_modal = Some(crate::app::ModalKind::AttMan); + let Ok(value) = u64::from_str_radix(handle, 16) else { + return Task::none(); + }; + let name = match self.tabs[i].scene.document.get_entity(Handle::new(value)) { + Some(EntityType::Insert(insert)) => insert.block_name.clone(), + _ => return Task::none(), + }; + let index = self.attman.as_ref().and_then(|state| { + state + .blocks + .iter() + .position(|b| b.name.eq_ignore_ascii_case(&name)) + }); + match index { + Some(index) => self.on_attman(AttManMsg::Block(index)), + None => { + if let Some(state) = self.attman.as_mut() { + state.error = Some( + crate::tf!("Block table contains no attributed block named {}.", name) + .into_owned(), + ); + } + Task::none() + } + } + } + + /// Write the working copy to the drawing. `finish` closes the undo step + /// (Apply / OK / Sync); otherwise the step stays open for the next live + /// edit. `sync` rebuilds the references' attributes even when "apply to + /// existing references" is off. Returns false when a field is invalid. + fn attman_commit(&mut self, i: usize, finish: bool, sync: bool) -> bool { + let Some(state) = self.attman.as_mut() else { + return false; + }; + if finish { + if let Some(error) = state + .rows + .iter() + .find_map(|row| crate::ui::window::attdef_dialog::tag_error(&row.def.tag)) + .or_else(|| { + crate::ui::window::attdef_dialog::tag_error(&state.drafts.tag) + .filter(|_| state.selected.is_some()) + }) + { + state.error = Some(crate::t!(error).into_owned()); + return false; + } + state.error = None; + } + let Some(name) = state.current_block().map(|b| b.name.clone()) else { + return false; + }; + let rows: Vec<(Handle, String, AttributeDefinition)> = state + .rows + .iter() + .map(|r| (r.handle, r.original_tag.clone(), r.def.clone())) + .collect(); + let removed = state.removed.clone(); + let write = state.dirty || sync; + let open = state.live_steps > 0 && self.tabs[i].history.pending.is_some(); + if write { + // One undo step; a live step something else closed starts anew. + if !open { + self.push_undo_snapshot(i, "BATTMAN"); + } + self.attman_write(i, &name, &rows, &removed, sync || dialog::prefs().apply_existing); + } + let Some(state) = self.attman.as_mut() else { + return false; + }; + if finish { + if state.live_steps > 0 || write { + self.finish_pending_history(i); + } + if let Some(state) = self.attman.as_mut() { + state.live_steps = 0; + state.dirty = false; + state.removed.clear(); + for row in &mut state.rows { + row.original_tag = row.def.tag.clone(); + } + } + } else if write { + if !open { + state.live_steps += 1; + } + // The references now hold the written tags. + if sync || dialog::prefs().apply_existing { + for row in &mut state.rows { + row.original_tag = row.def.tag.clone(); + } + } + } + true + } + + /// The definition list of `block` becomes `rows` (in that order) less + /// `removed`; with `sync`, every reference is rebuilt from it, keeping + /// each attribute's value through a tag rename. + fn attman_write( + &mut self, + i: usize, + block: &str, + rows: &[(Handle, String, AttributeDefinition)], + removed: &[Handle], + sync: bool, + ) { + let document = &mut self.tabs[i].scene.document; + for handle in removed { + if document.get_entity(*handle).is_some() { + document.remove_entity(*handle); + } + } + // Each definition keeps its object; the definitions take the slots + // the block's definitions held, in the new order. + for (handle, _, def) in rows { + if let Some(EntityType::AttributeDefinition(slot)) = document.get_entity_mut(*handle) { + let mut def = def.clone(); + def.common.handle = *handle; + def.common.owner_handle = slot.common.owner_handle; + *slot = def; + } + } + if let Some(br) = document.block_records.get_mut(block) { + br.entity_handles.retain(|h| !removed.contains(h)); + let mut order = rows.iter().map(|(handle, _, _)| *handle); + for slot in br.entity_handles.iter_mut() { + if rows.iter().any(|(handle, _, _)| handle == slot) { + if let Some(next) = order.next() { + *slot = next; + } + } + } + } + // Annotative definitions carry the scale context data. + for (handle, _, def) in rows { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + *handle, + def.flags.annotative, + ); + } + if sync { + // A renamed definition keeps the value each reference held. + let renames: Vec<(String, String)> = rows + .iter() + .filter(|(_, old, def)| !old.eq_ignore_ascii_case(&def.tag)) + .map(|(_, old, def)| (old.clone(), def.tag.clone())) + .collect(); + if !renames.is_empty() { + let inserts: Vec = self.tabs[i] + .scene + .document + .entities() + .filter_map(|e| match e { + EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(block) => { + Some(ins.common.handle) + } + _ => None, + }) + .collect(); + for handle in inserts { + if let Some(EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + for attribute in &mut ins.attributes { + if let Some((_, new)) = renames + .iter() + .find(|(old, _)| old.eq_ignore_ascii_case(&attribute.tag)) + { + attribute.tag = new.clone(); + } + } + } + } + } + self.attsync_block_with(i, block, false); + } + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } +} + +fn geom(def: &mut AttributeDefinition, field: &str, value: &str) { + let mut entity = EntityType::AttributeDefinition(def.clone()); + apply_geom_prop(&mut entity, field, value); + if let EntityType::AttributeDefinition(updated) = entity { + *def = updated; + } +} + +fn common(def: &mut AttributeDefinition, field: &str, value: &str) { + let mut entity = EntityType::AttributeDefinition(def.clone()); + apply_common_prop(&mut entity, field, value); + if let EntityType::AttributeDefinition(updated) = entity { + *def = updated; + } +} diff --git a/src/app/commands/block_cmdline.rs b/src/app/commands/block_cmdline.rs index 61b26bcd9..76b62aa2e 100644 --- a/src/app/commands/block_cmdline.rs +++ b/src/app/commands/block_cmdline.rs @@ -135,7 +135,12 @@ impl OpenCADStudio { .push_output("This drawing contains no attributed blocks."); return Some(Task::none()); } - self.open_attedit_dialog(); + let preselected = self.tabs[i].scene.selected_handles_in_order(); + return Some(self.open_attman(i, &preselected)); + } + cmd if cmd == "_ATTMAN_PICK" || cmd.starts_with("_ATTMAN_PICK ") => { + self.tabs[i].active_cmd = None; + return Some(self.attman_picked(i, cmd["_ATTMAN_PICK".len()..].trim())); } _ => return None, } @@ -166,6 +171,11 @@ impl OpenCADStudio { /// attributes whose definition is gone are dropped. Constant definitions /// carry no attribute. pub(in crate::app) fn attsync_block(&mut self, i: usize, block: &str) { + self.attsync_block_with(i, block, true); + } + + /// [`Self::attsync_block`], opening its own undo step only when `undo`. + pub(in crate::app) fn attsync_block_with(&mut self, i: usize, block: &str, undo: bool) { use codec::Entity as _; let doc = &self.tabs[i].scene.document; let attdefs: Vec = doc @@ -200,7 +210,9 @@ impl OpenCADStudio { if inserts.is_empty() { return; } - self.push_undo_snapshot(i, "ATTSYNC"); + if undo { + self.push_undo_snapshot(i, "ATTSYNC"); + } let mut changes = Vec::new(); for handle in inserts { let document = &self.tabs[i].scene.document; diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index e59fdbb38..9c92cbabe 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -1731,9 +1731,9 @@ mod tests { .join("\n") } - // ATTMAN and BATTMAN are the Block Attribute Manager; this build routes both - // to the attribute editor (ATTEDIT). A drawing without attributed blocks - // has nothing to manage, which both report instead of opening it. + // ATTMAN and BATTMAN open the Block Attribute Manager. A drawing without + // attributed blocks has nothing to manage, which both report instead of + // opening it. #[test] fn attman_and_battman_report_a_drawing_without_attributed_blocks() { for command in ["ATTMAN", "BATTMAN"] { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1b98f6377..933c8b6a2 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -5,6 +5,7 @@ use iced::Task; use std::path::PathBuf; mod attdef; +mod attman; mod block_cmdline; mod blocks; mod blockvars; diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index aa397d1f4..b5ab30ddd 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -120,6 +120,7 @@ pub(super) const NAMES: &[&str] = &[ "dialog_ok", "attdef_dialog", "field_dialog", + "attman_dialog", "sheet_set", "close_document", "toggle_properties", @@ -1547,6 +1548,96 @@ impl OpenCADStudio { _ => return Err(failure("invalid_value", "Unknown attdef dialog field")), }) } + // The open Block Attribute Manager: `block=`, `select=`, + // `up`, `down`, `remove`, `edit`, `tab=attribute|text|properties`, + // `tag=…`, `prompt=…`, `default=…`, `mode=:0|1`, + // `style=…`, `justify=MC`, `height=…`, `rotation=…`, `width=…`, `oblique=…`, + // `boundary=…`, `annotative=0|1`, `backwards=0|1`, `upside_down=0|1`, + // `layer=…`, `linetype=…`, `color=…`, `lineweight=…`, `column=:0|1`, + // `more_columns`, `apply_existing=0|1`, `emphasize=0|1`, `live=0|1`, + // `pick`, `sync`, `apply`, `ok`, `cancel`. + "attman_dialog" => { + use crate::ui::window::attman_dialog::{AttManMsg as A, Column, EditTab}; + let Some(state) = self.attman.as_ref() else { + return Err(failure("no_dialog", "The attribute manager is not open")); + }; + let value = string(req, "value")?; + let (key, v) = value.split_once('=').unwrap_or((value, "")); + let on = v == "1"; + Message::AttMan(match key { + "block" => A::Block( + state + .blocks + .iter() + .position(|b| b.name.eq_ignore_ascii_case(v)) + .ok_or_else(|| failure("invalid_value", "No such attributed block"))?, + ), + "select" => A::Select( + v.parse() + .ok() + .filter(|r: &usize| *r < state.rows.len()) + .ok_or_else(|| failure("invalid_value", "No such row"))?, + ), + "up" => A::MoveUp, + "down" => A::MoveDown, + "remove" => A::Remove, + "edit" => A::ToggleEdit, + "tab" => A::Tab(match v { + "text" => EditTab::Text, + "properties" => EditTab::Properties, + _ => EditTab::Attribute, + }), + "tag" => A::Tag(v.into()), + "prompt" => A::Prompt(v.into()), + "default" => A::Default(v.into()), + "mode" => { + let (mode, state) = v.split_once(':').unwrap_or((v, "1")); + let mode = match mode { + "invisible" => "invisible", + "verify" => "verify", + "preset" => "preset", + "multiline" => "multiline", + _ => return Err(failure("invalid_value", "Unknown mode")), + }; + A::Mode(mode, state == "1") + } + "style" => A::Style(v.into()), + "justify" => A::Justify( + crate::modules::draw::draw::attdef::Justify::from_keyword(v) + .ok_or_else(|| failure("invalid_value", "Unknown justification"))?, + ), + "height" => A::Height(v.into()), + "rotation" => A::Rotation(v.into()), + "width" => A::Width(v.into()), + "oblique" => A::Oblique(v.into()), + "boundary" => A::Boundary(v.into()), + "annotative" => A::Annotative(on), + "backwards" => A::Backwards(on), + "upside_down" => A::UpsideDown(on), + "layer" => A::Layer(v.into()), + "linetype" => A::Linetype(v.into()), + "color" => A::Color(v.into()), + "lineweight" => A::Lineweight(v.into()), + "column" => { + let (name, state) = v.split_once(':').unwrap_or((v, "1")); + A::Column( + Column::from_name(name) + .ok_or_else(|| failure("invalid_value", "Unknown column"))?, + state == "1", + ) + } + "more_columns" => A::MoreColumns, + "apply_existing" => A::ApplyExisting(on), + "emphasize" => A::Emphasize(on), + "live" => A::Live(on), + "pick" => A::PickBlock, + "sync" => A::Sync, + "apply" => A::Apply, + "ok" => A::Ok, + "cancel" => A::Cancel, + _ => return Err(failure("invalid_value", "Unknown attribute manager field")), + }) + } // One field of the open Field dialog: `category=`, `name=Date`, // `date=yyyy-MM-dd`, `case=0..4`, `file=1|2|3`, `ext=0|1`, `size=0..2`, // `sysvar=dimscale`, `diesel=…`, `named_type=`, `named=`, @@ -2119,6 +2210,9 @@ impl OpenCADStudio { Some(crate::app::ModalKind::Field) => { Message::FieldDialog(crate::ui::window::field_dialog::FieldDialogMsg::Ok) } + Some(crate::app::ModalKind::AttMan) => { + Message::AttMan(crate::ui::window::attman_dialog::AttManMsg::Ok) + } _ => return Err(failure("no_dialog", "No dialog with an OK button is open")), }, "close_document" => Message::TabClose(self.tabs[self.active_tab].id), diff --git a/src/app/mod.rs b/src/app/mod.rs index 8e3bebe25..fb979d557 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -585,6 +585,8 @@ pub(super) struct OpenCADStudio { /// ATTDEF dialog and Edit Attribute Definition dialog; `None` while closed. attdef_dialog: Option, attdef_edit: Option, + /// Block Attribute Manager working copy; `None` while it is closed. + attman: Option, /// Field dialog working copy; `None` while closed. field_dialog: Option, point_cloud_color_map: Option, @@ -2184,6 +2186,8 @@ pub enum ModalKind { GeometricTolerance, AttDef, AttDefEdit, + /// Block Attribute Manager (BATTMAN / ATTMAN). + AttMan, CountInvalidArea, Field, DraftingSettings, @@ -4395,6 +4399,8 @@ pub enum Message { /// An edit in one of the PDF dialogs. PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg), AttdefDialog(crate::ui::window::attdef_dialog::AttdefDialogMsg), + /// Block Attribute Manager edits. + AttMan(crate::ui::window::attman_dialog::AttManMsg), FieldDialog(crate::ui::window::field_dialog::FieldDialogMsg), /// An edit in the Edit Reference dialog. RefEditDialog(crate::ui::window::refedit_dialog::RefEditDialogMsg), @@ -4607,6 +4613,7 @@ impl OpenCADStudio { pdf_attach: None, point_cloud_attach: None, attdef_dialog: None, + attman: None, attdef_edit: None, field_dialog: None, point_cloud_color_map: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bcfb76142..547544318 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -232,6 +232,7 @@ impl OpenCADStudio { } Some(GeometricTolerance) => self.geometric_tolerance = None, Some(BlockDefinition) => self.block_definition = None, + Some(AttMan) => self.attman = None, Some(BlockEditPicker) => self.block_edit_picker = None, Some(BlockSaveAs) => self.block_save_as = None, Some(PdfAttach) => self.pdf_attach = None, @@ -2407,6 +2408,7 @@ impl OpenCADStudio { Message::PdfDialog(message) => self.update_pdf_dialog(message), Message::AttdefDialog(message) => self.on_attdef_dialog(message), + Message::AttMan(message) => self.on_attman(message), Message::FieldDialog(message) => self.on_field_dialog(message), Message::RefEditDialog(message) => self.update_refedit_dialog(message), Message::PcManager(message) => self.update_pc_manager(message), @@ -9571,6 +9573,13 @@ impl OpenCADStudio { self.close_field_dialog(); return Task::none(); } + // Closing the attribute manager is its Cancel: live changes + // are taken back. + if self.active_modal == Some(super::ModalKind::AttMan) { + return self.on_attman( + crate::ui::window::attman_dialog::AttManMsg::Cancel, + ); + } if matches!( self.active_modal, Some(super::ModalKind::AttDef | super::ModalKind::AttDefEdit) diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index dfb36033d..8a3d668fa 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -52,6 +52,7 @@ impl OpenCADStudio { Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::AttDef) => crate::t!("Attribute Definition").into_owned(), Some(K::AttDefEdit) => crate::t!("Edit Attribute Definition").into_owned(), + Some(K::AttMan) => crate::t!("Block Attribute Manager").into_owned(), Some(K::CountInvalidArea) => crate::t!("Count - Invalid Area").into_owned(), Some(K::Field) => crate::t!("Field").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), @@ -629,6 +630,12 @@ impl OpenCADStudio { super::super::ModalKind::CountInvalidArea => sized_flow(ex, 520, 260, |flow| { crate::ui::window::count_palette::invalid_area_view(&self.count_palette, flow) }), + super::super::ModalKind::AttMan => { + let state = self.attman.as_ref()?; + sized_flow(ex, 880, 660, |flow| { + crate::ui::window::attman_dialog::view(state, flow) + }) + } super::super::ModalKind::AttDefEdit => { let state = self.attdef_edit.as_ref()?; sized_flow(ex, 460, 220, |flow| { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 46a790cc3..d1d1c0cb5 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3,6 +3,18 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> { match source { + "Block Attribute Manager" => Some(("attman", "title")), + "Modes" => Some(("attman", "modes")), + "{} references" => Some(("attman", "references")), + "In drawing: {} · in model space: {}" => Some(("attman", "counts")), + "Columns:" => Some(("attman", "columns")), + "The Constant mode cannot be changed in the definition." => Some(("attman", "constant-note")), + "Show changes in the drawing as they are made" => Some(("attman", "live")), + "Apply changes to existing references" => Some(("attman", "apply-existing")), + "Emphasize duplicate tags" => Some(("attman", "emphasize")), + "Sync" => Some(("attman", "sync")), + "BATTMAN manages the attribute definitions of a block." => Some(("attman", "help")), + "Block table contains no attributed block named {}." => Some(("attman", "no-attributed-block")), "{} entities added" => Some(("ref-edit", "entities-added")), "Use REFCLOSE or the Refedit toolbar to end reference editing session." => Some(("ref-edit", "use-refclose")), "Specify opposite corner:" => Some(("ref-edit", "opposite-corner")), diff --git a/src/modules/insert/attman.rs b/src/modules/insert/attman.rs index d126d05da..1bfa5c452 100644 --- a/src/modules/insert/attman.rs +++ b/src/modules/insert/attman.rs @@ -8,3 +8,44 @@ pub fn tool() -> ToolDef { event: ModuleEvent::Command("ATTMAN".to_string()), } } + +/// The Block Attribute Manager's "Select block" button: one block reference +/// picked in the drawing, handed back as `_ATTMAN_PICK `. +pub struct AttmanPickCommand; + +impl crate::command::CadCommand for AttmanPickCommand { + fn name(&self) -> &'static str { + "BATTMAN" + } + + fn prompt(&self) -> String { + "Select a block:".into() + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: codec::Handle, _pt: glam::DVec3) -> crate::command::CmdResult { + if handle.is_null() { + return crate::command::CmdResult::NeedPoint; + } + crate::command::CmdResult::Dispatch(format!("_ATTMAN_PICK {:X}", handle.value())) + } + + fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult { + crate::command::CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> crate::command::CmdResult { + crate::command::CmdResult::Dispatch("_ATTMAN_PICK".into()) + } + + fn on_escape(&mut self) -> crate::command::CmdResult { + crate::command::CmdResult::Dispatch("_ATTMAN_PICK".into()) + } +} diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index a8677fe3c..1f4c63d3a 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -2,7 +2,7 @@ mod attdef; pub(crate) mod attedit; -mod attman; +pub(crate) mod attman; pub mod base_point; pub(crate) mod block_cmdline; mod content_browser; diff --git a/src/ui/window/attman_dialog.rs b/src/ui/window/attman_dialog.rs new file mode 100644 index 000000000..958356557 --- /dev/null +++ b/src/ui/window/attman_dialog.rs @@ -0,0 +1,1037 @@ +//! Block Attribute Manager (BATTMAN / ATTMAN): the attribute definitions of +//! one attributed block as a table, with their order, editing, removal and +//! the update of the existing references. +//! +//! Blocks are chips across the top; the columns the table shows are chips +//! under it; the row actions sit in a column beside it; the selected row is +//! edited in a card below (Attribute / Text / Properties) and the settings +//! are switches in the footer. + +use codec::entities::AttributeDefinition; +use codec::Handle; +use iced::widget::{ + button, column, container, pick_list, row, scrollable, text, text_input, toggler, Space, +}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::modules::draw::draw::attdef::Justify; +use crate::t; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, dialog_button, field_style}; +use crate::ui::window::pdf_dialogs::{accent_text, card_style, well_style}; + +static ICON_UP: &[u8] = include_bytes!("../../../assets/icons/ui/up.svg"); +static ICON_PICK: &[u8] = include_bytes!("../../../assets/icons/blocks/select_objects.svg"); + +/// The columns the table can show besides Tag, in the order of their chips. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Column { + Prompt, + Default, + Modes, + Annotative, + Style, + Height, + Layer, + Justify, + Rotation, + WidthFactor, + Oblique, + BoundaryWidth, + Linetype, + Color, + Lineweight, +} + +impl Column { + pub const ALL: [Column; 15] = [ + Column::Prompt, + Column::Default, + Column::Modes, + Column::Annotative, + Column::Style, + Column::Height, + Column::Layer, + Column::Justify, + Column::Rotation, + Column::WidthFactor, + Column::Oblique, + Column::BoundaryWidth, + Column::Linetype, + Column::Color, + Column::Lineweight, + ]; + /// The chips shown before "+N". + const FIRST: usize = 7; + + pub fn label(self) -> &'static str { + match self { + Column::Prompt => "Prompt", + Column::Default => "Default", + Column::Modes => "Modes", + Column::Annotative => "Annotative", + Column::Style => "Style", + Column::Height => "Height", + Column::Layer => "Layer", + Column::Justify => "Justification", + Column::Rotation => "Rotation", + Column::WidthFactor => "Width factor", + Column::Oblique => "Oblique angle", + Column::BoundaryWidth => "Boundary width", + Column::Linetype => "Linetype", + Column::Color => "Color", + Column::Lineweight => "Lineweight", + } + } + + pub fn from_name(name: &str) -> Option { + let key: String = name + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase(); + Column::ALL.into_iter().find(|c| { + c.label() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect::() + .eq_ignore_ascii_case(&key) + }) + } + + fn bit(self) -> u32 { + 1 << (self as u32) + } + + fn cell(self, def: &AttributeDefinition) -> String { + let num = |v: f64| crate::ui::window::block_palette::short(v); + match self { + Column::Prompt => def.prompt.clone(), + Column::Default => def.default_value.clone(), + Column::Modes => modes(def), + Column::Annotative => yes_no(def.flags.annotative), + Column::Style => style_name(def), + Column::Height => num(def.height), + Column::Layer => def.common.layer.clone(), + Column::Justify => t!(justify(def).label()).into_owned(), + Column::Rotation => num(def.rotation.to_degrees()), + Column::WidthFactor => num(def.width_factor), + Column::Oblique => num(def.oblique_angle.to_degrees()), + Column::BoundaryWidth => def + .embedded_mtext + .as_ref() + .map_or(String::new(), |m| num(m.rectangle_width)), + Column::Linetype => crate::ui::properties::linetype_display_name(&def.common.linetype), + Column::Color => color_label(def.common.color), + Column::Lineweight => lineweight_label(def.common.line_weight), + } + } +} + +/// What the dialog remembers between openings. +#[derive(Debug, Clone, Copy)] +pub struct AttManPrefs { + /// Bit set of [`Column`]s shown. + pub columns: u32, + /// Apply and Sync rebuild the attributes of the existing references. + pub apply_existing: bool, + /// Rows sharing a tag are drawn in the danger colour. + pub emphasize_duplicates: bool, + /// Every edit is written to the drawing at once. + pub live: bool, +} + +static PREFS: std::sync::Mutex = std::sync::Mutex::new(AttManPrefs { + columns: (1 << Column::Prompt as u32) + | (1 << Column::Default as u32) + | (1 << Column::Modes as u32) + | (1 << Column::Annotative as u32), + apply_existing: true, + emphasize_duplicates: true, + live: true, +}); + +pub fn prefs() -> std::sync::MutexGuard<'static, AttManPrefs> { + PREFS.lock().unwrap_or_else(|e| e.into_inner()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EditTab { + #[default] + Attribute, + Text, + Properties, +} + +/// One attributed block the dialog can show. +#[derive(Debug, Clone)] +pub struct AttManBlock { + pub name: String, + /// References anywhere in the drawing. + pub found: usize, + /// References in the current space. + pub in_space: usize, +} + +/// One attribute definition of the shown block. +#[derive(Debug, Clone)] +pub struct AttManRow { + /// The definition object in the block. + pub handle: Handle, + /// The tag the definition had when the block was loaded, for keeping the + /// references' values when it is renamed. + pub original_tag: String, + /// The working copy. + pub def: AttributeDefinition, +} + +/// The edit card's typed fields, kept as text while they are typed. +#[derive(Debug, Clone, Default)] +pub struct Drafts { + pub tag: String, + pub height: String, + pub rotation: String, + pub width: String, + pub oblique: String, + pub boundary: String, +} + +pub struct AttManState { + pub blocks: Vec, + pub block: usize, + pub rows: Vec, + /// Definitions removed from the shown block since it was loaded. + pub removed: Vec, + pub selected: Option, + pub tab: EditTab, + /// The edit card is shown. + pub edit_open: bool, + pub drafts: Drafts, + /// The working copy differs from the drawing. + pub dirty: bool, + /// Undo steps the live changes since the last Apply took (one unless + /// something closed the open step). + pub live_steps: usize, + /// The column chips past the first ones are shown. + pub more_columns: bool, + pub styles: Vec, + pub layers: Vec, + pub linetypes: Vec, + pub error: Option, +} + +impl AttManState { + pub fn current_block(&self) -> Option<&AttManBlock> { + self.blocks.get(self.block) + } + + pub fn selected_row(&self) -> Option<&AttManRow> { + self.selected.and_then(|i| self.rows.get(i)) + } + + /// Load the drafts from the selected row. + pub fn load_drafts(&mut self) { + let num = |v: f64| crate::ui::window::block_palette::short(v); + self.drafts = match self.selected_row() { + Some(row) => { + let d = &row.def; + Drafts { + tag: d.tag.clone(), + height: num(d.height), + rotation: num(d.rotation.to_degrees()), + width: num(d.width_factor), + oblique: num(d.oblique_angle.to_degrees()), + boundary: d + .embedded_mtext + .as_ref() + .map_or("0".into(), |m| num(m.rectangle_width)), + } + } + None => Drafts::default(), + }; + } + + /// Tags held by more than one row, upper-cased. + fn duplicate_tags(&self) -> Vec { + let mut seen = std::collections::HashMap::::new(); + for row in &self.rows { + *seen.entry(row.def.tag.to_ascii_uppercase()).or_default() += 1; + } + seen.into_iter() + .filter(|(_, n)| *n > 1) + .map(|(tag, _)| tag) + .collect() + } +} + +/// One edit in the dialog. +#[derive(Debug, Clone)] +pub enum AttManMsg { + Block(usize), + PickBlock, + Select(usize), + MoveUp, + MoveDown, + ToggleEdit, + Remove, + Tab(EditTab), + Column(Column, bool), + MoreColumns, + Tag(String), + Prompt(String), + Default(String), + /// "invisible", "verify", "preset" or "multiline". + Mode(&'static str, bool), + Style(String), + Justify(Justify), + Height(String), + Rotation(String), + Width(String), + Oblique(String), + Boundary(String), + Annotative(bool), + Backwards(bool), + UpsideDown(bool), + Layer(String), + Linetype(String), + Color(String), + Lineweight(String), + ApplyExisting(bool), + Emphasize(bool), + Live(bool), + Sync, + Apply, + Ok, + Cancel, + Help, + DismissError, +} + +fn msg(m: AttManMsg) -> Message { + Message::AttMan(m) +} + +// ── Cell text ───────────────────────────────────────────────────────────── + +fn yes_no(on: bool) -> String { + if on { t!("Yes") } else { t!("No") }.into_owned() +} + +/// Mode letters: I invisible, C constant, V verify, P preset, L locked +/// position, M multiple lines. +pub fn modes(def: &AttributeDefinition) -> String { + let f = &def.flags; + [ + (f.invisible, 'I'), + (f.constant, 'C'), + (f.verify, 'V'), + (f.preset, 'P'), + (f.locked_position || def.lock_position, 'L'), + (def.is_multiline, 'M'), + ] + .into_iter() + .filter_map(|(on, letter)| on.then_some(letter)) + .collect() +} + +pub fn justify(def: &AttributeDefinition) -> Justify { + Justify::from_alignment(def.horizontal_alignment, def.vertical_alignment) +} + +fn style_name(def: &AttributeDefinition) -> String { + if def.text_style.trim().is_empty() { + "Standard".into() + } else { + def.text_style.clone() + } +} + +/// The colours the Properties tab offers. +pub const COLORS: [&str; 9] = [ + "ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White", +]; + +pub fn color_label(color: codec::types::Color) -> String { + use codec::types::Color as C; + match color { + C::ByLayer => "ByLayer".into(), + C::ByBlock => "ByBlock".into(), + C::Index(n @ 1..=7) => COLORS[n as usize + 1].into(), + C::Index(n) => format!("{n}"), + C::Rgb { r, g, b } => format!("{r},{g},{b}"), + C::None => "None".into(), + } +} + +pub fn parse_color(label: &str) -> Option { + use codec::types::Color as C; + match COLORS.iter().position(|c| c.eq_ignore_ascii_case(label.trim())) { + Some(0) => Some(C::ByLayer), + Some(1) => Some(C::ByBlock), + Some(i) => Some(C::Index(i as u8 - 1)), + None => label + .trim() + .parse::() + .ok() + .filter(|n| (1..=255).contains(n)) + .map(C::Index), + } +} + +/// The lineweights the Properties tab offers, in 1/100 mm. +const LINEWEIGHTS: [i16; 24] = [ + 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, + 200, 211, +]; + +pub fn lineweight_label(lw: codec::types::LineWeight) -> String { + use codec::types::LineWeight as L; + match lw { + L::ByLayer => "ByLayer".into(), + L::ByBlock => "ByBlock".into(), + L::Default => "Default".into(), + L::Value(v) => format!("{:.2} mm", f64::from(v) / 100.0), + } +} + +pub fn parse_lineweight(label: &str) -> Option { + use codec::types::LineWeight as L; + let s = label.trim(); + match s.to_ascii_lowercase().as_str() { + "bylayer" => Some(L::ByLayer), + "byblock" => Some(L::ByBlock), + "default" => Some(L::Default), + _ => { + let mm = s.trim_end_matches("mm").trim().parse::().ok()?; + let v = (mm * 100.0).round() as i16; + LINEWEIGHTS.contains(&v).then_some(L::Value(v)) + } + } +} + +fn lineweight_options() -> Vec { + ["ByLayer", "ByBlock", "Default"] + .into_iter() + .map(str::to_string) + .chain( + LINEWEIGHTS + .iter() + .map(|v| lineweight_label(codec::types::LineWeight::Value(*v))), + ) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct JustifyItem(Justify); + +impl std::fmt::Display for JustifyItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&t!(self.0.label())) + } +} + +// ── View ────────────────────────────────────────────────────────────────── + +fn panel<'a>(content: impl Into>) -> Element<'a, Message> { + container(content) + .padding([10, 12]) + .width(Fill) + .style(card_style) + .into() +} + +/// A pill: framed in the accent colour while on. +fn pill<'a>(label: String, on: bool, message: Option) -> Element<'a, Message> { + let b = button(text(label).size(11)) + .padding([4, 11]) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let mut style = button_style(false)(theme, status); + style.border = Border { + width: 1.0, + radius: 12.0.into(), + color: if on { + palette.primary.base.color + } else { + palette.background.strong.color + }, + }; + if on { + style.background = Some(Background::Color( + palette.primary.base.color.scale_alpha(0.16), + )); + } + style + }); + match message { + Some(m) => b.on_press(m).into(), + None => b.into(), + } +} + +fn icon_button<'a>( + icon: &'static [u8], + rotation: f32, + message: Option, + danger: bool, +) -> Element<'a, Message> { + let enabled = message.is_some(); + let svg = iced::widget::svg(crate::ui::icons::themed_handle(icon)) + .width(14) + .height(14) + .rotation(iced::Radians(rotation)) + .style(move |theme: &Theme, _| iced::widget::svg::Style { + color: Some(if danger && enabled { + theme.palette().danger.base.color + } else if enabled { + theme.palette().background.base.text + } else { + theme.palette().background.base.text.scale_alpha(0.35) + }), + }); + let b = button(container(svg).center_x(Fill).center_y(Fill)) + .width(30) + .height(28) + .padding(0) + .style(button_style(false)); + match message { + Some(m) => b.on_press(m).into(), + None => b.into(), + } +} + +fn labeled_switch<'a>(label: String, on: bool, f: fn(bool) -> AttManMsg) -> Element<'a, Message> { + row![ + toggler(on).on_toggle(move |v| msg(f(v))).size(16.0), + text(label).size(11), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +fn field_row<'a>( + label: String, + value: &'a str, + on_input: Option AttManMsg>, +) -> Element<'a, Message> { + let input: Element<'a, Message> = match on_input { + Some(f) => text_input("", value) + .on_input(move |v| msg(f(v))) + .style(field_style) + .size(11) + .padding([4, 8]) + .width(Fill) + .into(), + None => crate::ui::read_only::field(value, 11.0, Fill), + }; + row![ + text(label) + .size(11) + .style(muted_style) + .width(Length::Fixed(96.0)), + input + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +fn choice_row<'a>( + label: String, + selected: String, + options: Vec, + on: Option AttManMsg>, +) -> Element<'a, Message> { + let list: Element<'a, Message> = match on { + Some(f) => pick_list(Some(selected), options, |s: &String| s.clone()) + .on_select(move |v| msg(f(v))) + .text_size(11) + .padding([3, 8]) + .width(Fill) + .into(), + None => crate::ui::read_only::field(&selected, 11.0, Fill), + }; + row![ + text(label) + .size(11) + .style(muted_style) + .width(Length::Fixed(96.0)), + list + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +fn segmented<'a>(current: EditTab) -> Element<'a, Message> { + let tabs = [ + (EditTab::Attribute, t!("Attribute").into_owned()), + (EditTab::Text, t!("Text").into_owned()), + (EditTab::Properties, t!("Properties").into_owned()), + ]; + let buttons = tabs.into_iter().map(|(tab, label)| { + button(text(label).size(11).width(Fill).align_x(iced::Center)) + .on_press(msg(AttManMsg::Tab(tab))) + .style(button_style(tab == current)) + .padding([5, 6]) + .width(Fill) + .into() + }); + container(iced::widget::Row::with_children(buttons.collect::>()).spacing(2)) + .padding(2) + .width(Length::Fixed(330.0)) + .style(well_style) + .into() +} + +fn table<'a>(state: &'a AttManState, columns: &[Column], emphasize: bool) -> Element<'a, Message> { + let duplicates = if emphasize { + state.duplicate_tags() + } else { + Vec::new() + }; + let header = { + let mut r = row![text(t!("Tag")).size(11).style(muted_style).width(Fill)]; + for c in columns { + r = r.push(text(t!(c.label())).size(11).style(muted_style).width(Fill)); + } + container(r.spacing(8)).padding([4, 8]) + }; + let rows = state.rows.iter().enumerate().map(|(index, entry)| { + let duplicate = duplicates.contains(&entry.def.tag.to_ascii_uppercase()); + let chosen = state.selected == Some(index); + let tint = move |theme: &Theme| text::Style { + color: duplicate.then(|| theme.palette().danger.base.color), + }; + let mut r = row![text(entry.def.tag.clone()).size(11).style(tint).width(Fill)]; + for c in columns { + r = r.push(text(c.cell(&entry.def)).size(11).style(tint).width(Fill)); + } + button(r.spacing(8)) + .on_press(msg(AttManMsg::Select(index))) + .padding([5, 8]) + .width(Fill) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let mut style = button::text(theme, status); + style.text_color = palette.background.base.text; + style.background = if chosen { + Some(Background::Color(palette.primary.base.color.scale_alpha(0.18))) + } else if matches!(status, button::Status::Hovered) { + Some(Background::Color(palette.background.strong.color)) + } else { + None + }; + style + }) + .into() + }); + column![ + header, + container(Space::new().height(1)) + .width(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.strong.color)), + ..Default::default() + }), + scrollable(column(rows.collect::>>()).spacing(1)) + .height(Length::Fixed(150.0)), + ] + .spacing(2) + .width(Fill) + .into() +} + +fn edit_card<'a>(state: &'a AttManState, row_: &'a AttManRow) -> Element<'a, Message> { + let def = &row_.def; + let title = text(format!("{} — {}", def.tag, t!("Edit")).to_uppercase()) + .size(10) + .style(accent_text); + let body: Element<'a, Message> = match state.tab { + EditTab::Attribute => { + let constant = def.flags.constant; + let mode = |key: &'static str, label: &str, on: bool| { + pill( + t!(label).into_owned(), + on, + Some(msg(AttManMsg::Mode(key, !on))), + ) + }; + let fields = column![ + field_row(t!("Tag").into_owned(), &state.drafts.tag, Some(AttManMsg::Tag)), + field_row( + t!("Prompt").into_owned(), + &def.prompt, + (!constant).then_some(AttManMsg::Prompt as fn(String) -> AttManMsg), + ), + field_row( + t!("Default").into_owned(), + &def.default_value, + Some(AttManMsg::Default), + ), + ] + .spacing(6) + .width(Fill); + let modes = column![ + row![ + mode("invisible", "Invisible", def.flags.invisible), + mode("verify", "Verify", def.flags.verify), + ] + .spacing(6), + row![ + mode("preset", "Preset", def.flags.preset), + mode("multiline", "Multiple lines", def.is_multiline), + ] + .spacing(6), + text(t!("The Constant mode cannot be changed in the definition.")) + .size(10) + .style(muted_style), + ] + .spacing(6) + .width(Length::Fixed(230.0)); + row![fields, modes].spacing(18).into() + } + EditTab::Text => { + let justify_list: Element<'a, Message> = if def.is_multiline { + crate::ui::read_only::field(&t!(justify(def).label()), 11.0, Fill) + } else { + pick_list( + Some(JustifyItem(justify(def))), + Justify::ALL.map(JustifyItem).to_vec(), + |j: &JustifyItem| j.to_string(), + ) + .on_select(|j: JustifyItem| msg(AttManMsg::Justify(j.0))) + .text_size(11) + .padding([3, 8]) + .width(Fill) + .into() + }; + let left = column![ + choice_row( + t!("Text Style").into_owned(), + style_name(def), + state.styles.clone(), + Some(AttManMsg::Style), + ), + row![ + text(t!("Justification")) + .size(11) + .style(muted_style) + .width(Length::Fixed(96.0)), + justify_list + ] + .spacing(8) + .align_y(iced::Center), + field_row( + t!("Height").into_owned(), + &state.drafts.height, + Some(AttManMsg::Height) + ), + field_row( + t!("Rotation").into_owned(), + &state.drafts.rotation, + Some(AttManMsg::Rotation) + ), + ] + .spacing(6) + .width(Fill); + let right = column![ + field_row( + t!("Width factor").into_owned(), + &state.drafts.width, + Some(AttManMsg::Width) + ), + field_row( + t!("Oblique angle").into_owned(), + &state.drafts.oblique, + Some(AttManMsg::Oblique) + ), + field_row( + t!("Boundary width").into_owned(), + &state.drafts.boundary, + def.is_multiline + .then_some(AttManMsg::Boundary as fn(String) -> AttManMsg), + ), + row![ + pill( + t!("Annotative").into_owned(), + def.flags.annotative, + Some(msg(AttManMsg::Annotative(!def.flags.annotative))), + ), + pill( + t!("Backwards").into_owned(), + def.text_generation_flags & 0x2 != 0, + Some(msg(AttManMsg::Backwards( + def.text_generation_flags & 0x2 == 0 + ))), + ), + pill( + t!("Upside down").into_owned(), + def.text_generation_flags & 0x4 != 0, + Some(msg(AttManMsg::UpsideDown( + def.text_generation_flags & 0x4 == 0 + ))), + ), + ] + .spacing(6), + ] + .spacing(6) + .width(Fill); + row![left, right].spacing(18).into() + } + EditTab::Properties => { + let mut linetypes = vec!["ByLayer".to_string(), "ByBlock".to_string()]; + linetypes.extend(state.linetypes.iter().cloned()); + let linetype = if def.common.linetype.is_empty() { + "ByLayer".to_string() + } else { + def.common.linetype.clone() + }; + row![ + column![ + choice_row( + t!("Layer").into_owned(), + def.common.layer.clone(), + state.layers.clone(), + Some(AttManMsg::Layer), + ), + choice_row( + t!("Linetype").into_owned(), + linetype, + linetypes, + Some(AttManMsg::Linetype), + ), + ] + .spacing(6) + .width(Fill), + column![ + choice_row( + t!("Color").into_owned(), + color_label(def.common.color), + COLORS.iter().map(|c| c.to_string()).collect(), + Some(AttManMsg::Color), + ), + choice_row( + t!("Lineweight").into_owned(), + lineweight_label(def.common.line_weight), + lineweight_options(), + Some(AttManMsg::Lineweight), + ), + ] + .spacing(6) + .width(Fill), + ] + .spacing(18) + .into() + } + }; + let live = prefs().live; + panel( + column![ + title, + segmented(state.tab), + body, + labeled_switch( + t!("Show changes in the drawing as they are made").into_owned(), + live, + AttManMsg::Live + ), + ] + .spacing(10), + ) +} + +pub fn view<'a>( + state: &'a AttManState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let prefs = *prefs(); + // ── Blocks ── + let mut chips = row![icon_button( + ICON_PICK, + 0.0, + Some(msg(AttManMsg::PickBlock)), + false + )] + .spacing(6) + .align_y(iced::Center); + for (index, block) in state.blocks.iter().enumerate() { + let on = index == state.block; + let label = if on { + format!( + "{} · {}", + block.name, + crate::tf!("{} references", block.found) + ) + } else { + block.name.clone() + }; + chips = chips.push(pill(label, on, Some(msg(AttManMsg::Block(index))))); + } + let counts = state.current_block().map_or(String::new(), |b| { + crate::tf!( + "In drawing: {} · in model space: {}", + b.found, + b.in_space + ) + .into_owned() + }); + let blocks_card = panel( + row![ + scrollable(chips).direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(3).scroller_width(3) + )), + Space::new().width(Fill), + text(counts).size(10).style(muted_style), + ] + .spacing(10) + .align_y(iced::Center), + ); + + // ── Table ── + let columns: Vec = Column::ALL + .into_iter() + .filter(|c| prefs.columns & c.bit() != 0) + .collect(); + let mut column_chips = row![text(t!("Columns:")).size(11).style(muted_style)] + .spacing(6) + .align_y(iced::Center); + let shown = if state.more_columns { + Column::ALL.len() + } else { + Column::FIRST + }; + for c in &Column::ALL[..shown] { + let on = prefs.columns & c.bit() != 0; + column_chips = column_chips.push(pill( + t!(c.label()).into_owned(), + on, + Some(msg(AttManMsg::Column(*c, !on))), + )); + } + column_chips = column_chips.push(pill( + if state.more_columns { + "−".to_string() + } else { + format!("+{}", Column::ALL.len() - Column::FIRST) + }, + false, + Some(msg(AttManMsg::MoreColumns)), + )); + let count = state.rows.len(); + let at = state.selected; + let actions = column![ + icon_button( + ICON_UP, + 0.0, + at.filter(|i| *i > 0).map(|_| msg(AttManMsg::MoveUp)), + false + ), + icon_button( + ICON_UP, + std::f32::consts::PI, + at.filter(|i| i + 1 < count).map(|_| msg(AttManMsg::MoveDown)), + false + ), + icon_button( + crate::ui::icons::MORE, + 0.0, + at.map(|_| msg(AttManMsg::ToggleEdit)), + false + ), + // The last attribute of a block cannot be removed. + icon_button( + crate::ui::icons::CLOSE, + 0.0, + at.filter(|_| count > 1).map(|_| msg(AttManMsg::Remove)), + true + ), + ] + .spacing(6); + let table_card = panel( + row![ + column![ + table(state, &columns, prefs.emphasize_duplicates), + scrollable(column_chips).direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(3).scroller_width(3) + )), + ] + .spacing(10) + .width(Fill), + actions, + ] + .spacing(10), + ); + + let mut content = column![blocks_card, table_card].spacing(10); + if state.edit_open { + if let Some(selected) = state.selected_row() { + content = content.push(edit_card(state, selected)); + } + } + if let Some(error) = &state.error { + content = content.push( + container( + row![ + crate::ui::icons::semantic( + include_bytes!("../../../assets/icons/ui/warning_triangle.svg"), + 15.0 + ), + text(error.clone()).size(11).width(Fill), + button(text("×").size(12)) + .on_press(msg(AttManMsg::DismissError)) + .style(button_style(false)) + .padding([1, 6]), + ] + .spacing(8) + .align_y(iced::Center), + ) + .padding([6, 10]) + .width(Fill) + .style(|theme: &Theme| { + let danger = theme.palette().danger.base.color; + container::Style { + background: Some(Background::Color(danger.scale_alpha(0.16))), + border: Border { + width: 1.0, + radius: 6.0.into(), + color: danger, + }, + ..Default::default() + } + }), + ); + } + let footer = row![ + button(text("?").size(12)) + .on_press(msg(AttManMsg::Help)) + .style(button_style(false)) + .padding([3, 10]), + labeled_switch( + t!("Apply changes to existing references").into_owned(), + prefs.apply_existing, + AttManMsg::ApplyExisting + ), + labeled_switch( + t!("Emphasize duplicate tags").into_owned(), + prefs.emphasize_duplicates, + AttManMsg::Emphasize + ), + Space::new().width(Fill), + dialog_button(t!("Sync"), msg(AttManMsg::Sync), false), + dialog_button(t!("Apply"), msg(AttManMsg::Apply), false), + dialog_button(t!("OK"), msg(AttManMsg::Ok), true), + ] + .spacing(12) + .align_y(iced::Center); + container(content.push(footer)) + .padding([10, 14]) + .width(sizing.width) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + ..Default::default() + }) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index b12716252..9dbc3430c 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -2,6 +2,7 @@ pub mod about; pub mod alias_editor; pub mod annotation_data; pub mod attdef_dialog; +pub mod attman_dialog; pub mod attribute_editor; pub mod auto_constrain_settings; pub mod block_definition; From 3bd828d0f57f2c22f0b7a6c8f9d22dc9f3cfe0a5 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:34:58 +0300 Subject: [PATCH 2/2] Block Attribute Manager: one undo step per Apply Live changes are taken back quietly before Apply, OK or Sync write the list again, so each leaves exactly one undo step even when something closed the open live step in between; Cancel takes them back without a command-line notice. References keep their values through renames made live. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/attman.rs | 65 +++++++++++++++++++++------------- src/app/history.rs | 12 +++++-- src/ui/window/attman_dialog.rs | 5 +-- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/app/commands/attman.rs b/src/app/commands/attman.rs index f3bac6acf..fc3f0c553 100644 --- a/src/app/commands/attman.rs +++ b/src/app/commands/attman.rs @@ -111,6 +111,7 @@ impl OpenCADStudio { Some(EntityType::AttributeDefinition(def)) => Some(AttManRow { handle: *h, original_tag: def.tag.clone(), + live_tag: def.tag.clone(), def: def.clone(), }), _ => None, @@ -252,12 +253,9 @@ impl OpenCADStudio { return Task::none(); } AttManMsg::Cancel => { - let steps = state.live_steps; + self.attman_withdraw_live(); self.attman = None; self.close_active_modal(); - if steps > 0 { - self.undo_steps(steps); - } return Task::none(); } _ => { @@ -374,10 +372,26 @@ impl OpenCADStudio { } } - /// Write the working copy to the drawing. `finish` closes the undo step - /// (Apply / OK / Sync); otherwise the step stays open for the next live - /// edit. `sync` rebuilds the references' attributes even when "apply to - /// existing references" is off. Returns false when a field is invalid. + /// Take back the changes written live since the last Apply, quietly. + fn attman_withdraw_live(&mut self) { + let Some(state) = self.attman.as_mut() else { + return; + }; + let steps = std::mem::take(&mut state.live_steps); + for row in &mut state.rows { + row.live_tag = row.original_tag.clone(); + } + if steps > 0 { + self.undo_steps_reported(steps, false); + } + } + + /// Write the working copy to the drawing. `finish` (Apply / OK / Sync) + /// leaves exactly one undo step: the live changes are taken back and the + /// whole list is written again in one step. Otherwise (a live edit) the + /// write joins the open live step. `sync` rebuilds the references' + /// attributes even when "apply to existing references" is off. Returns + /// false when a field is invalid. fn attman_commit(&mut self, i: usize, finish: bool, sync: bool) -> bool { let Some(state) = self.attman.as_mut() else { return false; @@ -396,48 +410,51 @@ impl OpenCADStudio { return false; } state.error = None; + self.attman_withdraw_live(); } + let Some(state) = self.attman.as_mut() else { + return false; + }; let Some(name) = state.current_block().map(|b| b.name.clone()) else { return false; }; let rows: Vec<(Handle, String, AttributeDefinition)> = state .rows .iter() - .map(|r| (r.handle, r.original_tag.clone(), r.def.clone())) + .map(|r| (r.handle, r.live_tag.clone(), r.def.clone())) .collect(); let removed = state.removed.clone(); let write = state.dirty || sync; - let open = state.live_steps > 0 && self.tabs[i].history.pending.is_some(); + let synced = sync || dialog::prefs().apply_existing; + // A live step something else closed starts a new one. + let open = !finish && state.live_steps > 0 && self.tabs[i].history.pending.is_some(); if write { - // One undo step; a live step something else closed starts anew. if !open { self.push_undo_snapshot(i, "BATTMAN"); } - self.attman_write(i, &name, &rows, &removed, sync || dialog::prefs().apply_existing); + self.attman_write(i, &name, &rows, &removed, synced); + } + if finish && write { + self.finish_pending_history(i); } let Some(state) = self.attman.as_mut() else { return false; }; if finish { - if state.live_steps > 0 || write { - self.finish_pending_history(i); - } - if let Some(state) = self.attman.as_mut() { - state.live_steps = 0; - state.dirty = false; - state.removed.clear(); - for row in &mut state.rows { - row.original_tag = row.def.tag.clone(); - } + state.dirty = false; + state.removed.clear(); + for row in &mut state.rows { + row.original_tag = row.def.tag.clone(); + row.live_tag = row.def.tag.clone(); } } else if write { if !open { state.live_steps += 1; } // The references now hold the written tags. - if sync || dialog::prefs().apply_existing { + if synced { for row in &mut state.rows { - row.original_tag = row.def.tag.clone(); + row.live_tag = row.def.tag.clone(); } } } diff --git a/src/app/history.rs b/src/app/history.rs index 1dd3a7aed..40bfc1694 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1287,6 +1287,12 @@ impl OpenCADStudio { } pub(super) fn undo_steps(&mut self, steps: usize) { + self.undo_steps_reported(steps, true); + } + + /// [`Self::undo_steps`]; `report` false takes the steps back without + /// the command-line notice (a dialog withdrawing its own live changes). + pub(super) fn undo_steps_reported(&mut self, steps: usize, report: bool) { let i = self.active_tab; self.finish_pending_history(i); let available = self.tabs[i].history.undo_stack.len(); @@ -1353,8 +1359,10 @@ impl OpenCADStudio { native_graph_changed, &changes, ); - self.command_line - .push_output(crate::tf!("Undo: {last_label}").as_ref()); + if report { + self.command_line + .push_output(crate::tf!("Undo: {last_label}").as_ref()); + } } pub(super) fn redo_steps(&mut self, steps: usize) { diff --git a/src/ui/window/attman_dialog.rs b/src/ui/window/attman_dialog.rs index 958356557..b9fb15e40 100644 --- a/src/ui/window/attman_dialog.rs +++ b/src/ui/window/attman_dialog.rs @@ -179,9 +179,10 @@ pub struct AttManBlock { pub struct AttManRow { /// The definition object in the block. pub handle: Handle, - /// The tag the definition had when the block was loaded, for keeping the - /// references' values when it is renamed. + /// The tag the definition had when the block was loaded or applied. pub original_tag: String, + /// The tag the references hold now (changed by live writes). + pub live_tag: String, /// The working copy. pub def: AttributeDefinition, }