diff --git a/assets/icons/ui/pin_active.svg b/assets/icons/ui/pin_active.svg
new file mode 100644
index 000000000..3a291545c
--- /dev/null
+++ b/assets/icons/ui/pin_active.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/ui/resize_left.svg b/assets/icons/ui/resize_left.svg
new file mode 100644
index 000000000..bacde830e
--- /dev/null
+++ b/assets/icons/ui/resize_left.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl
index 0c06f9d5c..d2055bb35 100644
--- a/locales/ar-SA/opencadstudio.ftl
+++ b/locales/ar-SA/opencadstudio.ftl
@@ -6176,3 +6176,12 @@ count-feature =
.invalid-boundary = كائن حد منطقة العد غير صالح. حدد خطًا متعددًا مغلقًا يتكون من قطع مستقيمة ولا يتقاطع مع نفسه.
.block-not-found = تعذر العثور على الكتلة "__ocs_fmt_0__".
.invalid-point = نقطة غير صالحة.
+
+dock =
+ .allow-docking = السماح بالإرساء
+ .auto-hide = إخفاء تلقائي
+ .dock-left = إرساء إلى اليسار
+ .dock-right = إرساء إلى اليمين
+ .new-group = مجموعة جديدة
+ .browser = المستعرض
+ .node-graph = مخطط العقد
diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl
index 6fc37dd5e..c843de1e1 100644
--- a/locales/bg-BG/opencadstudio.ftl
+++ b/locales/bg-BG/opencadstudio.ftl
@@ -6164,3 +6164,12 @@ count-feature =
.invalid-boundary = Невалиден обект за граница на областта за преброяване. Изберете затворена полилиния от отсечки, която не се самопресича.
.block-not-found = Блокът "__ocs_fmt_0__" не може да бъде намерен.
.invalid-point = Невалидна точка.
+
+dock =
+ .allow-docking = Разреши закачане
+ .auto-hide = Автоматично скриване
+ .dock-left = Закачи вляво
+ .dock-right = Закачи вдясно
+ .new-group = Нова група
+ .browser = Браузър
+ .node-graph = Граф на възлите
diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl
index 703e93249..1ff56bf14 100644
--- a/locales/cs-CZ/opencadstudio.ftl
+++ b/locales/cs-CZ/opencadstudio.ftl
@@ -6161,3 +6161,12 @@ count-feature =
.invalid-boundary = Neplatný objekt hranice oblasti počítání. Vyberte uzavřenou křivku složenou z úseček, která neprotíná sama sebe.
.block-not-found = Blok "__ocs_fmt_0__" nelze najít.
.invalid-point = Neplatný bod.
+
+dock =
+ .allow-docking = Povolit ukotvení
+ .auto-hide = Automaticky skrýt
+ .dock-left = Ukotvit vlevo
+ .dock-right = Ukotvit vpravo
+ .new-group = Nová skupina
+ .browser = Prohlížeč
+ .node-graph = Graf uzlů
diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl
index fc2e78090..b7ee02cae 100644
--- a/locales/de-DE/opencadstudio.ftl
+++ b/locales/de-DE/opencadstudio.ftl
@@ -6162,3 +6162,12 @@ count-feature =
.invalid-boundary = Ungültiges Begrenzungsobjekt für den Zählbereich. Wählen Sie eine geschlossene Polylinie aus Liniensegmenten, die sich nicht selbst schneidet.
.block-not-found = Block "__ocs_fmt_0__" wurde nicht gefunden.
.invalid-point = Ungültiger Punkt.
+
+dock =
+ .allow-docking = Andocken zulassen
+ .auto-hide = Automatisch ausblenden
+ .dock-left = Links andocken
+ .dock-right = Rechts andocken
+ .new-group = Neue Gruppe
+ .browser = Browser
+ .node-graph = Knotengraph
diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl
index c967b0a70..9d7e93337 100644
--- a/locales/el-GR/opencadstudio.ftl
+++ b/locales/el-GR/opencadstudio.ftl
@@ -6164,3 +6164,12 @@ count-feature =
.invalid-boundary = Μη έγκυρο αντικείμενο ορίου περιοχής καταμέτρησης. Επιλέξτε μια κλειστή πολυγραμμή από ευθύγραμμα τμήματα που δεν τέμνει τον εαυτό της.
.block-not-found = Το μπλοκ "__ocs_fmt_0__" δεν βρέθηκε.
.invalid-point = Μη έγκυρο σημείο.
+
+dock =
+ .allow-docking = Να επιτρέπεται η πρόσδεση
+ .auto-hide = Αυτόματη απόκρυψη
+ .dock-left = Πρόσδεση αριστερά
+ .dock-right = Πρόσδεση δεξιά
+ .new-group = Νέα ομάδα
+ .browser = Περιηγητής
+ .node-graph = Γράφος κόμβων
diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl
index b7586cc90..744f93a65 100644
--- a/locales/en-US/opencadstudio.ftl
+++ b/locales/en-US/opencadstudio.ftl
@@ -6165,3 +6165,12 @@ count-feature =
.invalid-boundary = Invalid count area boundary object. Select a closed polyline consisting of line segments and does not intersect itself.
.block-not-found = Block "__ocs_fmt_0__" cannot be found.
.invalid-point = Invalid point.
+
+dock =
+ .allow-docking = Allow docking
+ .auto-hide = Auto-hide
+ .dock-left = Dock left
+ .dock-right = Dock right
+ .new-group = New group
+ .browser = Browser
+ .node-graph = Node Graph
diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl
index 946c8cfde..11687f899 100644
--- a/locales/es-ES/opencadstudio.ftl
+++ b/locales/es-ES/opencadstudio.ftl
@@ -6164,3 +6164,12 @@ count-feature =
.invalid-boundary = Objeto de contorno de área de recuento no válido. Seleccione una polilínea cerrada formada por segmentos de línea que no se corte a sí misma.
.block-not-found = No se encuentra el bloque "__ocs_fmt_0__".
.invalid-point = Punto no válido.
+
+dock =
+ .allow-docking = Permitir acoplar
+ .auto-hide = Ocultar automáticamente
+ .dock-left = Acoplar a la izquierda
+ .dock-right = Acoplar a la derecha
+ .new-group = Nuevo grupo
+ .browser = Explorador
+ .node-graph = Grafo de nodos
diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl
index 415efa229..bc4d6dbb2 100644
--- a/locales/fi-FI/opencadstudio.ftl
+++ b/locales/fi-FI/opencadstudio.ftl
@@ -6161,3 +6161,12 @@ count-feature =
.invalid-boundary = Virheellinen laskenta-alueen rajaobjekti. Valitse suljettu, janoista koostuva moniviiva, joka ei leikkaa itseään.
.block-not-found = Lohkoa "__ocs_fmt_0__" ei löydy.
.invalid-point = Virheellinen piste.
+
+dock =
+ .allow-docking = Salli telakointi
+ .auto-hide = Piilota automaattisesti
+ .dock-left = Telakoi vasemmalle
+ .dock-right = Telakoi oikealle
+ .new-group = Uusi ryhmä
+ .browser = Selain
+ .node-graph = Solmukaavio
diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl
index 5ea608821..e27a389f4 100644
--- a/locales/fr-FR/opencadstudio.ftl
+++ b/locales/fr-FR/opencadstudio.ftl
@@ -6163,3 +6163,12 @@ count-feature =
.invalid-boundary = Objet de contour de zone de comptage non valide. Sélectionnez une polyligne fermée composée de segments de ligne et qui ne se recoupe pas.
.block-not-found = Le bloc "__ocs_fmt_0__" est introuvable.
.invalid-point = Point non valide.
+
+dock =
+ .allow-docking = Autoriser l'ancrage
+ .auto-hide = Masquage automatique
+ .dock-left = Ancrer à gauche
+ .dock-right = Ancrer à droite
+ .new-group = Nouveau groupe
+ .browser = Navigateur
+ .node-graph = Graphe de nœuds
diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl
index 98cdadeaa..d73c8d401 100644
--- a/locales/hi-IN/opencadstudio.ftl
+++ b/locales/hi-IN/opencadstudio.ftl
@@ -6152,3 +6152,12 @@ count-feature =
.invalid-boundary = अमान्य गणना क्षेत्र सीमा ऑब्जेक्ट। रेखा खंडों से बनी ऐसी बंद पॉलीलाइन चुनें जो स्वयं को प्रतिच्छेद न करे।
.block-not-found = ब्लॉक "__ocs_fmt_0__" नहीं मिला।
.invalid-point = अमान्य बिंदु।
+
+dock =
+ .allow-docking = डॉकिंग की अनुमति दें
+ .auto-hide = स्वतः छिपाएँ
+ .dock-left = बाएँ डॉक करें
+ .dock-right = दाएँ डॉक करें
+ .new-group = नया समूह
+ .browser = ब्राउज़र
+ .node-graph = नोड ग्राफ़
diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl
index 35f480324..a18d25a9b 100644
--- a/locales/hu-HU/opencadstudio.ftl
+++ b/locales/hu-HU/opencadstudio.ftl
@@ -6150,3 +6150,12 @@ count-feature =
.invalid-boundary = Érvénytelen számlálási kontúrobjektum. Válasszon szakaszokból álló, zárt, önmagát nem metsző vonalláncot.
.block-not-found = A(z) "__ocs_fmt_0__" blokk nem található.
.invalid-point = Érvénytelen pont.
+
+dock =
+ .allow-docking = Dokkolás engedélyezése
+ .auto-hide = Automatikus elrejtés
+ .dock-left = Dokkolás balra
+ .dock-right = Dokkolás jobbra
+ .new-group = Új csoport
+ .browser = Böngésző
+ .node-graph = Csomópontgráf
diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl
index 2a817ad42..9ae5c0d42 100644
--- a/locales/it-IT/opencadstudio.ftl
+++ b/locales/it-IT/opencadstudio.ftl
@@ -6163,3 +6163,12 @@ count-feature =
.invalid-boundary = Oggetto di contorno dell'area di conteggio non valido. Selezionare una polilinea chiusa composta da segmenti di linea che non si interseca.
.block-not-found = Impossibile trovare il blocco "__ocs_fmt_0__".
.invalid-point = Punto non valido.
+
+dock =
+ .allow-docking = Consenti ancoraggio
+ .auto-hide = Nascondi automaticamente
+ .dock-left = Ancora a sinistra
+ .dock-right = Ancora a destra
+ .new-group = Nuovo gruppo
+ .browser = Browser
+ .node-graph = Grafo dei nodi
diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl
index bf7531772..e09121acc 100644
--- a/locales/ja-JP/opencadstudio.ftl
+++ b/locales/ja-JP/opencadstudio.ftl
@@ -6152,3 +6152,12 @@ count-feature =
.invalid-boundary = カウント領域の境界オブジェクトが無効です。線分で構成され、自己交差しない閉じたポリラインを選択してください。
.block-not-found = ブロック "__ocs_fmt_0__" が見つかりません。
.invalid-point = 点が無効です。
+
+dock =
+ .allow-docking = ドッキングを許可
+ .auto-hide = 自動的に隠す
+ .dock-left = 左にドッキング
+ .dock-right = 右にドッキング
+ .new-group = 新しいグループ
+ .browser = ブラウザー
+ .node-graph = ノードグラフ
diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl
index 9e5fe1cd2..fc7b52230 100644
--- a/locales/ko-KR/opencadstudio.ftl
+++ b/locales/ko-KR/opencadstudio.ftl
@@ -6163,3 +6163,12 @@ count-feature =
.invalid-boundary = 개수 영역 경계 객체가 잘못되었습니다. 선분으로 구성되고 자체 교차하지 않는 닫힌 폴리선을 선택하십시오.
.block-not-found = 블록 "__ocs_fmt_0__"을(를) 찾을 수 없습니다.
.invalid-point = 점이 잘못되었습니다.
+
+dock =
+ .allow-docking = 도킹 허용
+ .auto-hide = 자동 숨기기
+ .dock-left = 왼쪽에 도킹
+ .dock-right = 오른쪽에 도킹
+ .new-group = 새 그룹
+ .browser = 브라우저
+ .node-graph = 노드 그래프
diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl
index f1b0e6d70..71e4dcad5 100644
--- a/locales/lo-LA/opencadstudio.ftl
+++ b/locales/lo-LA/opencadstudio.ftl
@@ -6165,3 +6165,12 @@ count-feature =
.invalid-boundary = ວັດຖຸຂອບເຂດພື້ນທີ່ການນັບບໍ່ຖືກຕ້ອງ. ກະລຸນາເລືອກເສັ້ນ polyline ແບບປິດທີ່ປະກອບດ້ວຍສ່ວນເສັ້ນຊື່ ແລະ ບໍ່ຕັດກັນເອງ.
.block-not-found = ບໍ່ພົບເຫັນບລັອກ "__ocs_fmt_0__".
.invalid-point = ຈຸດບໍ່ຖືກຕ້ອງ.
+
+dock =
+ .allow-docking = ອະນຸຍາດການຕິດຂອບ
+ .auto-hide = ເຊື່ອງອັດຕະໂນມັດ
+ .dock-left = ຕິດຂອບຊ້າຍ
+ .dock-right = ຕິດຂອບຂວາ
+ .new-group = ກຸ່ມໃໝ່
+ .browser = ບຣາວເຊີ
+ .node-graph = ກຣາຟໂນດ
diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl
index 7c025bc99..80ef8724d 100644
--- a/locales/nl-NL/opencadstudio.ftl
+++ b/locales/nl-NL/opencadstudio.ftl
@@ -6162,3 +6162,12 @@ count-feature =
.invalid-boundary = Ongeldig grensobject voor het telgebied. Selecteer een gesloten polylijn die uit lijnsegmenten bestaat en zichzelf niet snijdt.
.block-not-found = Blok "__ocs_fmt_0__" kan niet worden gevonden.
.invalid-point = Ongeldig punt.
+
+dock =
+ .allow-docking = Koppelen toestaan
+ .auto-hide = Automatisch verbergen
+ .dock-left = Links koppelen
+ .dock-right = Rechts koppelen
+ .new-group = Nieuwe groep
+ .browser = Browser
+ .node-graph = Knooppuntgrafiek
diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl
index ff2cb1470..b2c44d546 100644
--- a/locales/pl-PL/opencadstudio.ftl
+++ b/locales/pl-PL/opencadstudio.ftl
@@ -6163,3 +6163,12 @@ count-feature =
.invalid-boundary = Nieprawidłowy obiekt obwiedni obszaru liczenia. Wybierz zamkniętą polilinię złożoną z odcinków, która nie przecina samej siebie.
.block-not-found = Nie można znaleźć bloku "__ocs_fmt_0__".
.invalid-point = Nieprawidłowy punkt.
+
+dock =
+ .allow-docking = Zezwalaj na dokowanie
+ .auto-hide = Autoukrywanie
+ .dock-left = Zadokuj po lewej
+ .dock-right = Zadokuj po prawej
+ .new-group = Nowa grupa
+ .browser = Przeglądarka
+ .node-graph = Graf węzłów
diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl
index 4c62b07e1..92a940a8d 100644
--- a/locales/pt-BR/opencadstudio.ftl
+++ b/locales/pt-BR/opencadstudio.ftl
@@ -6164,3 +6164,12 @@ count-feature =
.invalid-boundary = Objeto de contorno da área de contagem inválido. Selecione uma polilinha fechada composta de segmentos de linha e que não se cruze.
.block-not-found = O bloco "__ocs_fmt_0__" não foi encontrado.
.invalid-point = Ponto inválido.
+
+dock =
+ .allow-docking = Permitir encaixe
+ .auto-hide = Ocultar automaticamente
+ .dock-left = Encaixar à esquerda
+ .dock-right = Encaixar à direita
+ .new-group = Novo grupo
+ .browser = Navegador
+ .node-graph = Grafo de nós
diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl
index 474783a6b..5bf008a66 100644
--- a/locales/ru-RU/opencadstudio.ftl
+++ b/locales/ru-RU/opencadstudio.ftl
@@ -6165,3 +6165,12 @@ count-feature =
.invalid-boundary = Недопустимый объект границы области подсчёта. Выберите замкнутую полилинию из отрезков, которая не пересекает саму себя.
.block-not-found = Блок "__ocs_fmt_0__" не найден.
.invalid-point = Недопустимая точка.
+
+dock =
+ .allow-docking = Разрешить закрепление
+ .auto-hide = Автоскрытие
+ .dock-left = Закрепить слева
+ .dock-right = Закрепить справа
+ .new-group = Новая группа
+ .browser = Обозреватель
+ .node-graph = Граф узлов
diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl
index cd0a5250a..07531cada 100644
--- a/locales/tr-TR/opencadstudio.ftl
+++ b/locales/tr-TR/opencadstudio.ftl
@@ -6087,3 +6087,12 @@ count-feature =
.invalid-boundary = Geçersiz sayım alanı sınır nesnesi. Doğru parçalarından oluşan, kendisiyle kesişmeyen kapalı bir çoklu çizgi seçin.
.block-not-found = "__ocs_fmt_0__" bloğu bulunamadı.
.invalid-point = Geçersiz nokta.
+
+dock =
+ .allow-docking = Yerleştirmeye izin ver
+ .auto-hide = Otomatik gizle
+ .dock-left = Sola yerleştir
+ .dock-right = Sağa yerleştir
+ .new-group = Yeni grup
+ .browser = Tarayıcı
+ .node-graph = Düğüm Grafiği
diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl
index 4a56903d2..9a70bd46a 100644
--- a/locales/zh-CN/opencadstudio.ftl
+++ b/locales/zh-CN/opencadstudio.ftl
@@ -6147,3 +6147,12 @@ count-feature =
.invalid-boundary = 无效的计数区域边界对象。请选择由直线段组成且不自交的闭合多段线。
.block-not-found = 找不到块 "__ocs_fmt_0__"。
.invalid-point = 无效的点。
+
+dock =
+ .allow-docking = 允许停靠
+ .auto-hide = 自动隐藏
+ .dock-left = 停靠到左侧
+ .dock-right = 停靠到右侧
+ .new-group = 新建组
+ .browser = 浏览器
+ .node-graph = 节点图
diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl
index 68aa10442..afd769830 100644
--- a/locales/zh-TW/opencadstudio.ftl
+++ b/locales/zh-TW/opencadstudio.ftl
@@ -6159,3 +6159,12 @@ count-feature =
.invalid-boundary = 無效的計數區域邊界物件。請選取由線段組成且不自我相交的封閉聚合線。
.block-not-found = 找不到圖塊 "__ocs_fmt_0__"。
.invalid-point = 無效的點。
+
+dock =
+ .allow-docking = 允許停駐
+ .auto-hide = 自動隱藏
+ .dock-left = 停駐到左側
+ .dock-right = 停駐到右側
+ .new-group = 新增群組
+ .browser = 瀏覽器
+ .node-graph = 節點圖
diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs
index a6739ab1e..b89871f4e 100644
--- a/src/app/commands/blocks.rs
+++ b/src/app/commands/blocks.rs
@@ -929,18 +929,7 @@ impl OpenCADStudio {
if self.show_external_references {
// Always open expanded so the panel is immediately usable;
// dock it on the right if the user closed it from the layout.
- if self
- .dock
- .location(crate::ui::dock::PanelId::ExternalReferences)
- .is_none()
- {
- self.dock.dock(
- crate::ui::dock::PanelId::ExternalReferences,
- crate::app::config::DockSide::Right,
- usize::MAX,
- );
- }
- self.dock_expanded = Some(crate::ui::dock::PanelId::ExternalReferences);
+ self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences);
self.refresh_xref_manager();
}
}
@@ -1784,7 +1773,7 @@ mod tests {
let _ = app.run_command_line("BLOCKPALETTE");
assert!(app.show_block_palette);
assert!(
- app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette),
+ app.dock_peek == Some(crate::ui::dock::PanelId::BlockPalette),
"palette must open expanded, not as the collapsed bar"
);
// BLOCKSPALETTE only opens; BLOCKSPALETTECLOSE closes.
diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs
index 157ebfe42..88674128c 100644
--- a/src/app/commands/count.rs
+++ b/src/app/commands/count.rs
@@ -259,13 +259,9 @@ impl OpenCADStudio {
self.count_palette.show = open;
self.ribbon.set_count_palette(open);
if open {
- if self.dock.location(id).is_none() {
- self.dock
- .dock(id, crate::app::config::DockSide::Right, usize::MAX);
- }
- self.dock_expanded = Some(id);
- } else if self.dock_expanded == Some(id) {
- self.dock_expanded = None;
+ self.dock_open_at_default(id);
+ } else {
+ self.dock_unpeek(id);
}
}
diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs
index cd448e0e5..7b922bec0 100644
--- a/src/app/commands/fileops.rs
+++ b/src/app/commands/fileops.rs
@@ -384,7 +384,9 @@ impl OpenCADStudio {
"ORTHO" => return Some(Task::done(Message::ToggleOrtho)),
"PARALLEL" => return Some(Task::done(Message::SetProjection(true))),
"PERSP" => return Some(Task::done(Message::SetProjection(false))),
- "LAYERS" => return Some(Task::done(Message::ToggleLayers)),
+ // LAYERS opens the Layer Manager pallet (its close button or
+ // the toggle message close it).
+ "LAYERS" => self.open_layers_pallet(),
// SCRIPT — run a command script: each non-comment
// line is fed through the same command path the `--script` startup
diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs
index 6a4a996f5..03b810729 100644
--- a/src/app/commands/layerprops.rs
+++ b/src/app/commands/layerprops.rs
@@ -704,15 +704,7 @@ impl OpenCADStudio {
// rearrange themselves on upgrade for a panel they have
// not asked for.
let id = crate::ui::dock::PanelId::Browser;
- if self.dock.location(id).is_none() {
- // dock() clamps an out-of-range index to the end of
- // the edge, so this appends below whatever is there.
- self.dock
- .dock(id, crate::app::config::DockSide::Right, usize::MAX);
- }
- // Open expanded so it is usable straight away; the pin
- // button still collapses it.
- self.dock_expanded = Some(id);
+ self.dock_open_at_default(id);
self.command_line
.push_output(crate::t!("Browser opened.").as_ref());
} else {
diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs
index 3adfa5231..6e1a4e099 100644
--- a/src/app/commands/pdf_underlay.rs
+++ b/src/app/commands/pdf_underlay.rs
@@ -444,17 +444,11 @@ impl OpenCADStudio {
"POINTCLOUDMANAGER" => {
let id = crate::ui::dock::PanelId::PointCloudManager;
self.pc_manager.show = true;
- if self.dock.location(id).is_none() {
- self.dock
- .dock(id, crate::app::config::DockSide::Right, usize::MAX);
- }
- self.dock_expanded = Some(id);
+ self.dock_open_at_default(id);
}
"POINTCLOUDMANAGERCLOSE" => {
self.pc_manager.show = false;
- if self.dock_expanded == Some(crate::ui::dock::PanelId::PointCloudManager) {
- self.dock_expanded = None;
- }
+ self.dock_unpeek(crate::ui::dock::PanelId::PointCloudManager);
}
"_PCUNCROP" => self
.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()),
diff --git a/src/app/commands/sheet_set.rs b/src/app/commands/sheet_set.rs
index 5365f2fde..a0af7cf63 100644
--- a/src/app/commands/sheet_set.rs
+++ b/src/app/commands/sheet_set.rs
@@ -660,13 +660,9 @@ impl OpenCADStudio {
self.sheet_set.show = on;
self.ribbon.set_sheet_set(on);
if on {
- if self.dock.location(id).is_none() {
- self.dock
- .dock(id, crate::app::config::DockSide::Right, usize::MAX);
- }
- self.dock_expanded = Some(id);
- } else if self.dock_expanded == Some(id) {
- self.dock_expanded = None;
+ self.dock_open_at_default(id);
+ } else {
+ self.dock_unpeek(id);
}
}
diff --git a/src/app/dock/mod.rs b/src/app/dock/mod.rs
new file mode 100644
index 000000000..153c2de95
--- /dev/null
+++ b/src/app/dock/mod.rs
@@ -0,0 +1,12 @@
+//! The app side of docking: pallets docked on the drawing view's edges (one
+//! group shown per edge, listed in an icon strip) or floating over it.
+//!
+//! The persisted layout and its pure geometry live in [`crate::ui::dock`];
+//! this module holds the transient state handling (`update`) and drawing
+//! (`view`).
+
+mod update;
+mod view;
+
+#[cfg(test)]
+mod tests;
diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs
new file mode 100644
index 000000000..ede7c6154
--- /dev/null
+++ b/src/app/dock/tests.rs
@@ -0,0 +1,670 @@
+//! Dock behaviour through the app: drags, hover, menus, and the real view.
+
+use crate::app::{Message, OpenCADStudio};
+
+fn fresh() -> OpenCADStudio {
+ let mut app = OpenCADStudio::new_for_test();
+ app.automation_op(r#"{"op":"new"}"#);
+ app
+}
+
+#[test]
+fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
+ let mut app = fresh();
+ app.show_block_palette = true;
+ let id = crate::ui::dock::PanelId::BlockPalette;
+ let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
+ assert!(app.dock.auto_hides(id), "pin enables auto-hide");
+ let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
+ assert!(!app.dock.auto_hides(id), "second pin disables auto-hide");
+ let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
+ assert!(!app.show_block_palette, "close dismisses the sidebar");
+}
+
+/// A hermetic app with a 1600×900 workspace and the default dock layout
+/// (Properties left, block palette right), both shown.
+fn dock_app() -> OpenCADStudio {
+ let mut app = fresh();
+ // Tests load the user's persisted config; reset the dock to a known
+ // state so this stays hermetic.
+ app.dock = Default::default();
+ app.dock.ensure_settings();
+ app.show_properties = true;
+ app.show_block_palette = true;
+ let i = app.active_tab;
+ app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
+ app.win_size = (1600.0, 900.0).into();
+ app
+}
+
+/// Where the current dock drag would land, if it has started.
+fn drag_target(app: &OpenCADStudio) -> Option {
+ app.dock_drag
+ .and_then(|d| d.movement())
+ .and_then(|(_, _, target)| target)
+}
+
+/// Press `id`'s title bar at `from`, drag to `to`, and return the target
+/// shown just before release.
+fn drag_panel(
+ app: &mut OpenCADStudio,
+ id: crate::ui::dock::PanelId,
+ from: iced::Point,
+ to: iced::Point,
+) -> Option {
+ use crate::ui::dock::DockMsg;
+ let _ = app.on_dock(DockMsg::DockGrab(id));
+ let _ = app.on_dock(DockMsg::DragMove(from));
+ let _ = app.on_dock(DockMsg::DragMove(to));
+ let target = drag_target(app);
+ let _ = app.on_dock(DockMsg::DragRelease);
+ target
+}
+
+#[test]
+fn dropping_on_a_pallets_top_or_bottom_half_stacks_above_or_below_it() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DropTarget, PanelId};
+ let mut app = dock_app();
+ let id = PanelId::BlockPalette;
+ assert_eq!(app.dock.location(id), Some((DockSide::Right, 0)));
+ let target = drag_panel(
+ &mut app,
+ id,
+ iced::Point::new(1500.0, 10.0),
+ iced::Point::new(150.0, 300.0),
+ );
+ assert_eq!(
+ target,
+ Some(DropTarget::Join {
+ side: DockSide::Left,
+ group: 0,
+ index: 0
+ })
+ );
+ assert_eq!(
+ app.dock.left[0].panels,
+ vec![PanelId::BlockPalette, PanelId::Properties]
+ );
+ assert!(app.dock.right.is_empty());
+ // The bottom half of a pallet stacks below it.
+ let target = drag_panel(
+ &mut app,
+ id,
+ iced::Point::new(150.0, 100.0),
+ iced::Point::new(150.0, 800.0),
+ );
+ assert_eq!(
+ target,
+ Some(DropTarget::Join {
+ side: DockSide::Left,
+ group: 0,
+ index: 2
+ })
+ );
+ assert_eq!(
+ app.dock.left[0].panels,
+ vec![PanelId::Properties, PanelId::BlockPalette]
+ );
+}
+
+#[test]
+fn dock_dragging_a_lone_pallet_over_its_own_group_is_a_no_op() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DropTarget, PanelId};
+ let mut app = dock_app();
+ let before = app.dock.clone();
+ // The right edge's only group holds just the block palette; drag it
+ // around over itself.
+ let target = drag_panel(
+ &mut app,
+ PanelId::BlockPalette,
+ iced::Point::new(1500.0, 10.0),
+ iced::Point::new(1450.0, 600.0),
+ );
+ assert_eq!(
+ target,
+ Some(DropTarget::Join {
+ side: DockSide::Right,
+ group: 0,
+ index: 1
+ })
+ );
+ assert_eq!(app.dock, before);
+}
+
+#[test]
+fn dock_click_without_drag_changes_nothing() {
+ let mut app = dock_app();
+ let before = app.dock.clone();
+ let id = crate::ui::dock::PanelId::BlockPalette;
+ let p = iced::Point::new(1500.0, 10.0);
+ let target = drag_panel(&mut app, id, p, iced::Point::new(1502.0, 11.0));
+ assert_eq!(target, None, "movement under the threshold is a click");
+ assert_eq!(app.dock, before);
+}
+
+#[test]
+fn dock_drop_below_the_strip_groups_starts_a_new_shown_group() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DropTarget, PanelId};
+ let mut app = dock_app();
+ let (_, plus_top) = app.dock_strip_layout(DockSide::Left);
+ let target = drag_panel(
+ &mut app,
+ PanelId::BlockPalette,
+ iced::Point::new(1500.0, 10.0),
+ iced::Point::new(18.0, plus_top + 10.0),
+ );
+ assert_eq!(
+ target,
+ Some(DropTarget::Edge {
+ side: DockSide::Left,
+ index: 1
+ })
+ );
+ assert_eq!(app.dock.left.len(), 2);
+ assert_eq!(app.dock.left[1].panels, vec![PanelId::BlockPalette]);
+ // The new group is the one shown: the column holds only the palette.
+ assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
+ assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]);
+}
+
+#[test]
+fn dock_icon_press_switches_the_shown_group() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockGroup, DockMsg, PanelId};
+ let mut app = dock_app();
+ app.show_browser = true;
+ app.dock.left = vec![
+ DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]),
+ DockGroup::single(PanelId::Browser),
+ ];
+ app.dock.right.clear();
+ // Group 0 shows both stacked pallets.
+ assert_eq!(app.dock_slot_spans(DockSide::Left).len(), 2);
+ let _ = app.on_dock(DockMsg::IconPress(PanelId::Browser));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
+ assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]);
+ // Each group keeps its own width.
+ app.dock.set_group_width(DockSide::Left, 1, 400.0);
+ assert_eq!(app.dock_column_width(DockSide::Left), 400.0);
+ let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ assert_eq!(app.dock_column_width(DockSide::Left), 260.0);
+}
+
+#[test]
+fn dock_drop_over_the_viewport_floats_the_panel() {
+ use crate::ui::dock::{DropTarget, PanelId};
+ let mut app = dock_app();
+ let target = drag_panel(
+ &mut app,
+ PanelId::BlockPalette,
+ iced::Point::new(1500.0, 10.0),
+ iced::Point::new(800.0, 400.0),
+ );
+ assert!(matches!(target, Some(DropTarget::Float { .. })));
+ assert!(app.dock.right.is_empty());
+ let f = app
+ .dock
+ .float_rect(PanelId::BlockPalette)
+ .expect("floating");
+ assert!(f.x > 600.0 && f.x < 800.0 && f.y > 350.0 && f.y < 400.0);
+ // Dragging a floating panel keeps the grab point under the pointer.
+ let target = drag_panel(
+ &mut app,
+ PanelId::BlockPalette,
+ iced::Point::new(f.x + 20.0, f.y + 10.0),
+ iced::Point::new(f.x + 120.0, f.y + 60.0),
+ );
+ assert_eq!(
+ target,
+ Some(DropTarget::Float {
+ x: f.x + 100.0,
+ y: f.y + 50.0
+ })
+ );
+ // Opening a floating panel must not re-dock it.
+ assert!(app.dock.is_placed(PanelId::BlockPalette));
+}
+
+#[test]
+fn dock_dragging_an_icon_into_another_group_lands_between_its_icons() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockGroup, DockMsg, DropTarget, PanelId, STRIP_CELL_H};
+ let mut app = dock_app();
+ app.show_browser = true;
+ app.dock.left = vec![
+ DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]),
+ DockGroup::single(PanelId::BlockPalette),
+ ];
+ app.dock.right.clear();
+ let (layout, _) = app.dock_strip_layout(DockSide::Left);
+ // Drag the Blocks icon (group 1) to between Properties and Browser.
+ let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(
+ 20.0,
+ layout[1].icons_top + 10.0,
+ )));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(
+ 20.0,
+ layout[0].icons_top + STRIP_CELL_H + 2.0,
+ )));
+ assert_eq!(
+ drag_target(&app),
+ Some(DropTarget::Join {
+ side: DockSide::Left,
+ group: 0,
+ index: 1
+ })
+ );
+ let _ = app.on_dock(DockMsg::DragRelease);
+ assert_eq!(app.dock.left.len(), 1, "the emptied group goes away");
+ assert_eq!(
+ app.dock.left[0].panels,
+ vec![PanelId::Properties, PanelId::BlockPalette, PanelId::Browser]
+ );
+}
+
+#[test]
+fn dock_grip_drag_moves_a_whole_group() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockGroup, DockMsg, PanelId};
+ let mut app = dock_app();
+ app.show_browser = true;
+ app.dock.left = vec![
+ DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]),
+ DockGroup::single(PanelId::BlockPalette),
+ ];
+ app.dock.right.clear();
+ let (layout, plus_top) = app.dock_strip_layout(DockSide::Left);
+ let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(
+ 18.0,
+ layout[0].top + 2.0,
+ )));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0)));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ assert_eq!(app.dock.left[0].panels, vec![PanelId::BlockPalette]);
+ assert_eq!(
+ app.dock.left[1].panels,
+ vec![PanelId::Properties, PanelId::Browser]
+ );
+ // A group dropped over the viewport does not float or split up.
+ let before = app.dock.clone();
+ let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0)));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0)));
+ assert_eq!(drag_target(&app), None);
+ let _ = app.on_dock(DockMsg::DragRelease);
+ assert_eq!(app.dock, before);
+}
+
+#[test]
+fn dock_closed_pallets_take_no_room_and_empty_groups_hide() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockGroup, PanelId};
+ let mut app = dock_app();
+ app.dock.left = vec![
+ DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties]),
+ DockGroup::single(PanelId::Browser),
+ ];
+ app.show_block_palette = false;
+ app.show_browser = false;
+ // Only Properties is open: one listed group, full height.
+ assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0]);
+ assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]);
+ // A shown group with nothing open falls back to one that has.
+ app.dock.show_group(DockSide::Left, 1);
+ assert_eq!(app.dock_shown_group(DockSide::Left), Some(0));
+}
+
+#[test]
+fn dock_splitter_drag_changes_pallet_heights() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockGroup, DockMsg, PanelId};
+ let mut app = dock_app();
+ app.dock.left = vec![DockGroup::stack(vec![
+ PanelId::BlockPalette,
+ PanelId::Properties,
+ ])];
+ app.dock.right.clear();
+ let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 0, 1));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0)));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 675.0)));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ // 225 px of a 900 px edge holding 2 weight units = 0.5 units.
+ let w = &app.dock.left[0].weights;
+ assert!((w[0] - 1.5).abs() < 1e-4 && (w[1] - 0.5).abs() < 1e-4);
+ let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left, 0));
+ assert_eq!(app.dock.left[0].weights, vec![1.0, 1.0]);
+}
+
+#[test]
+fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockGroup, DockMsg, PanelId};
+ let mut app = dock_app();
+ app.show_browser = true;
+ app.dock.left.push(DockGroup::single(PanelId::Browser));
+ // The pin on any docked pallet turns auto-hide on for its edge.
+ let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties));
+ assert!(app.dock.edge_auto_hide(DockSide::Left));
+ assert!(!app.dock.edge_auto_hide(DockSide::Right));
+ assert!(!app.dock_edge_expanded(DockSide::Left));
+ // Hovering an icon flies its group out.
+ let _ = app.on_dock(DockMsg::Hover(PanelId::Browser));
+ // ...but only once the pointer rests: the reveal waits for the delay.
+ assert!(!app.dock_edge_expanded(DockSide::Left));
+ let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen));
+ assert!(app.dock_edge_expanded(DockSide::Left));
+ assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
+ // Leaving and coming back within the delay keeps it open.
+ let _ = app.on_dock(DockMsg::HoverExit);
+ let stale = app.dock_hover_gen;
+ let _ = app.on_dock(DockMsg::HoverStay);
+ let _ = app.on_dock(DockMsg::HoverSettled(stale));
+ assert!(app.dock_edge_expanded(DockSide::Left));
+ let _ = app.on_dock(DockMsg::HoverExit);
+ let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen));
+ assert!(!app.dock_edge_expanded(DockSide::Left));
+ // Without auto-hide, hovering does not switch groups (clicks do).
+ let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties));
+ let _ = app.on_dock(DockMsg::Hover(PanelId::Properties));
+ assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
+ assert!(app.dock_edge_expanded(DockSide::Left));
+}
+
+#[test]
+fn dock_float_resize_grows_the_panel() {
+ use crate::ui::dock::{DockMsg, FloatPanel, PanelId};
+ let mut app = dock_app();
+ app.dock.float(FloatPanel::new(
+ PanelId::BlockPalette,
+ 100.0,
+ 100.0,
+ 260.0,
+ 300.0,
+ ));
+ let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, false));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(360.0, 400.0)));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(400.0, 450.0)));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ let f = app.dock.float_rect(PanelId::BlockPalette).unwrap();
+ assert_eq!((f.w, f.h), (300.0, 350.0));
+ // The bottom-left grip grows the panel leftward, keeping its right edge.
+ let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0)));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 450.0)));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ let g = app.dock.float_rect(PanelId::BlockPalette).unwrap();
+ assert_eq!((g.x, g.w, g.h), (f.x - 40.0, 340.0, 350.0));
+}
+
+#[test]
+fn dock_edge_menu_adds_and_hides_pallets() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockMsg, PanelId};
+ let mut app = dock_app();
+ app.show_browser = false;
+ let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left)));
+ // Checking a closed pallet opens it as a new, shown group here.
+ let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
+ assert!(app.show_browser);
+ assert_eq!(app.dock.left.len(), 2);
+ assert_eq!(app.dock.left[1].panels, vec![PanelId::Browser]);
+ assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
+ assert_eq!(app.dock_edge_menu, None, "the menu closes after adding");
+ let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left)));
+ // Unchecking hides it but keeps its place.
+ let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
+ assert!(!app.show_browser);
+ assert_eq!(
+ app.dock.location(PanelId::Browser),
+ Some((DockSide::Left, 1))
+ );
+ // Opening it again from the same edge reuses that place.
+ let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
+ assert_eq!(app.dock.left.len(), 2);
+ // A pallet open on the other edge is not checked here: picking it
+ // moves it over instead of hiding it.
+ let _ = app.on_dock(DockMsg::EdgeMenuToggle(
+ DockSide::Left,
+ PanelId::BlockPalette,
+ ));
+ assert!(app.show_block_palette);
+ assert!(app.dock.right.is_empty());
+ assert_eq!(
+ app.dock.location(PanelId::BlockPalette),
+ Some((DockSide::Left, 2))
+ );
+}
+
+#[test]
+fn dock_divider_resizes_only_its_group() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockGroup, DockMsg, PanelId};
+ let mut app = dock_app();
+ app.dock.left = vec![
+ DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]),
+ DockGroup::single(PanelId::Count),
+ ];
+ app.dock.right.clear();
+ let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0)));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0)));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ // The group was 260 wide (its widest pallet); it is now 290, while
+ // the other group and the pallets' own widths are untouched.
+ assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.0);
+ assert_eq!(app.dock_group_width(DockSide::Left, 1), 280.0);
+ assert_eq!(app.dock.settings(PanelId::Properties).width, 250.0);
+}
+
+#[test]
+fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() {
+ use crate::ui::dock::PanelId;
+ let mut app = dock_app();
+ let _ = app.update(Message::ToggleLayers);
+ assert!(app.show_layers);
+ assert!(app.active_modal.is_none(), "no modal backdrop");
+ let f = app
+ .dock
+ .float_rect(PanelId::Layers)
+ .expect("floats on first use");
+ assert!(f.x > 0.0 && f.y >= 0.0);
+ let _ = app.update(Message::ToggleLayers);
+ assert!(!app.show_layers);
+ // Its place is kept for next time.
+ assert!(app.dock.float_rect(PanelId::Layers).is_some());
+}
+
+/// A headless renderer for driving the real view.
+fn headless_renderer() -> iced::Renderer {
+ iced_test::futures::futures::executor::block_on(
+ ::new(
+ iced_test::core::renderer::Settings::default(),
+ None,
+ ),
+ )
+ .expect("headless renderer")
+}
+
+/// Size the real view is laid out at.
+const VIEW_SIZE: iced::Size = iced::Size::new(1600.0, 900.0);
+
+/// Centre of the first text `label` in the real view.
+fn find_in_view(app: &OpenCADStudio, label: &str) -> iced::Point {
+ use iced_test::core::widget;
+ use iced_test::runtime::user_interface::{Cache, UserInterface};
+ use iced_test::Selector;
+ let mut renderer = headless_renderer();
+ let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, Cache::default(), &mut renderer);
+ let mut find = Selector::find(label);
+ ui.operate(&renderer, &mut widget::operation::black_box(&mut find));
+ match widget::Operation::finish(&find) {
+ widget::operation::Outcome::Some(Some(target)) => {
+ target.visible_bounds().expect("visible").center()
+ }
+ _ => panic!("{label} not found"),
+ }
+}
+
+/// Drive the real view through iced's `UserInterface` with the pointer at
+/// `at`: feed `events` one at a time, and between events hand the
+/// published messages to the app and rebuild the view while keeping the
+/// widget-tree cache, as the runtime does.
+fn drive_view(app: &mut OpenCADStudio, at: iced::Point, events: &[iced::Event]) {
+ use iced_test::core::{mouse, shell, window};
+ use iced_test::runtime::user_interface::{Cache, UserInterface};
+ let mut renderer = headless_renderer();
+ let mut cache = Cache::default();
+ let cursor = mouse::Cursor::Available(at);
+ for event in events {
+ let mut messages = Vec::new();
+ {
+ let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, cache, &mut renderer);
+ let _ = ui.update(
+ &window::Headless,
+ &shell::Waker::noop(),
+ std::slice::from_ref(event),
+ cursor,
+ &mut renderer,
+ &mut messages,
+ );
+ cache = ui.into_cache();
+ }
+ for message in messages {
+ let _ = app.update(message);
+ }
+ }
+}
+
+#[test]
+fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() {
+ use crate::ui::dock::PanelId;
+ let mut app = dock_app();
+ let press = iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
+ let release = iced::Event::Mouse(iced::mouse::Event::ButtonReleased(
+ iced::mouse::Button::Left,
+ ));
+ // The block palette's title text sits in its docked title bar.
+ let title = find_in_view(&app, "Blocks");
+ drive_view(
+ &mut app,
+ title,
+ &[press.clone(), release.clone(), press, release],
+ );
+ assert!(
+ app.dock.float_rect(PanelId::BlockPalette).is_some(),
+ "a double-click on a docked title bar floats the pallet"
+ );
+}
+
+#[test]
+fn hovering_a_strip_icon_reveals_an_auto_hiding_edge_in_the_real_view() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockMsg, PanelId, STRIP_CELL_H, STRIP_PAD};
+ let mut app = dock_app();
+ // Mirror the layout onto the left so the block palette's title marks
+ // where the workspace starts, then auto-hide the right edge.
+ let title = find_in_view(&app, "Blocks");
+ let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette));
+ assert!(!app.dock_edge_expanded(DockSide::Right));
+ // The title bar text sits ~13 px below the workspace top; the strip's
+ // first icon is centred STRIP_PAD + half a cell below it.
+ let workspace_top = title.y - 13.0;
+ let icon = iced::Point::new(
+ VIEW_SIZE.width - crate::ui::dock::DOCK_STRIP_W / 2.0,
+ workspace_top + STRIP_PAD + STRIP_CELL_H / 2.0,
+ );
+ drive_view(
+ &mut app,
+ icon,
+ &[iced::Event::Mouse(iced::mouse::Event::CursorMoved {
+ position: icon,
+ })],
+ );
+ let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen));
+ assert!(
+ app.dock_edge_expanded(DockSide::Right),
+ "hovering the icon reveals the auto-hiding edge"
+ );
+}
+
+#[test]
+fn layers_ribbon_button_opens_the_pallet_without_staying_highlighted() {
+ let mut app = dock_app();
+ // The ribbon's LAYERS button runs the LAYERS command.
+ let click = || Message::RibbonToolClick {
+ tool_id: "LAYERS".to_string(),
+ event: crate::modules::ModuleEvent::Command("LAYERS".to_string()),
+ };
+ let _ = app.update(click());
+ assert!(app.show_layers);
+ assert_eq!(app.ribbon.active_tool(), None);
+ // Pressing it again keeps it open, like the Blocks palette's button.
+ let _ = app.update(click());
+ assert!(app.show_layers);
+}
+
+#[test]
+fn dock_double_clicks_float_and_dock_panels() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockMsg, PanelId};
+ let mut app = dock_app();
+ let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties));
+ assert!(app.dock.left.is_empty());
+ let f = app.dock.float_rect(PanelId::Properties).expect("floating");
+ assert!(f.x > 0.0, "floats clear of the left edge");
+ let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right));
+ assert!(app.dock.floating.is_empty());
+ assert_eq!(
+ app.dock.location(PanelId::Properties),
+ Some((DockSide::Right, 1))
+ );
+}
+
+#[test]
+fn blockpalette_width_reset() {
+ use crate::app::config::DockSide;
+ let mut app = dock_app();
+ let id = crate::ui::dock::PanelId::BlockPalette;
+ app.dock.set_group_width(DockSide::Right, 0, 500.0);
+ let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
+ assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0);
+}
+
+#[test]
+fn floating_menu_docks_and_allow_docking_keeps_a_drag_floating() {
+ use crate::app::config::DockSide;
+ use crate::ui::dock::{DockMsg, DropTarget, PanelId};
+ let mut app = dock_app();
+ let _ = app.on_dock(DockMsg::FloatOut(PanelId::BlockPalette));
+ // With Allow docking off, a drag onto the left edge's strip stays
+ // floating.
+ let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette)));
+ let _ = app.on_dock(DockMsg::FloatDockingToggle(PanelId::BlockPalette));
+ assert_eq!(app.dock_float_menu, None);
+ assert!(!app.dock.float_rect(PanelId::BlockPalette).unwrap().docking);
+ let _ = app.on_dock(DockMsg::DockGrab(PanelId::BlockPalette));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(500.0, 300.0)));
+ let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 60.0)));
+ assert!(matches!(drag_target(&app), Some(DropTarget::Float { .. })));
+ let _ = app.on_dock(DockMsg::DragRelease);
+ let f = app
+ .dock
+ .float_rect(PanelId::BlockPalette)
+ .expect("still floating");
+ assert!(!f.docking, "moving it keeps the choice");
+ // The menu's Dock left still docks it.
+ let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette)));
+ let _ = app.on_dock(DockMsg::DockTo(PanelId::BlockPalette, DockSide::Left));
+ assert_eq!(app.dock_float_menu, None);
+ assert_eq!(
+ app.dock.location(PanelId::BlockPalette).map(|(s, _)| s),
+ Some(DockSide::Left)
+ );
+}
diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs
new file mode 100644
index 000000000..e526c7d11
--- /dev/null
+++ b/src/app/dock/update.rs
@@ -0,0 +1,788 @@
+//! Dock state changes: drags, hover, open / close, and the queries the view
+//! and drop targeting share.
+
+use crate::app::{Message, OpenCADStudio};
+
+/// Pointer travel (px) before pressing a title bar or icon becomes a drag,
+/// so a plain click never moves anything.
+const DOCK_DRAG_THRESHOLD: f32 = 5.0;
+
+/// Resolves once the dock hover delay has passed (a sleeping helper thread;
+/// the web build has none and resolves at once).
+fn hover_delay() -> impl std::future::Future