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 { + #[cfg(not(target_arch = "wasm32"))] + let rx = { + let (tx, rx) = iced::futures::channel::oneshot::channel::<()>(); + std::thread::spawn(move || { + std::thread::sleep(crate::ui::dock::DOCK_HOVER_DELAY); + let _ = tx.send(()); + }); + rx + }; + async move { + #[cfg(not(target_arch = "wasm32"))] + let _ = rx.await; + } +} + +impl OpenCADStudio { + /// Dock chrome interaction (grab / pin / resize / hover / move) applied to + /// whichever panel the message names. + pub(crate) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { + use crate::app::config::DockSide; + use crate::ui::dock::{DockDrag, DockMsg, FloatPanel}; + match m { + DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)), + DockMsg::IconPress(id) => { + // An icon press shows the icon's group and may also start + // dragging the pallet; a plain click never passes the drag + // threshold. + if self.dock.show_group_of(id) { + self.save_config(); + } + self.dock_begin(DockDrag::moving(id, None), Some(id)); + } + DockMsg::GroupGrab(side, gi) => { + if let Some(&id) = self.dock_group_visible(side, gi).first() { + self.dock_begin(DockDrag::moving(id, Some((side, gi))), Some(id)); + } + } + DockMsg::ResizeGrab(id) => self.dock_begin(DockDrag::Width(id), Some(id)), + DockMsg::SplitGrab(side, group, upper, lower) => self.dock_begin( + DockDrag::Split { + side, + group, + upper, + lower, + }, + None, + ), + DockMsg::FloatResizeGrab(panel, from_left) => { + self.dock.raise_float(panel); + self.dock_begin(DockDrag::FloatSize { panel, from_left }, None); + } + DockMsg::SplitReset(side, gi) => { + self.dock.reset_splits(side, gi); + self.save_config(); + } + DockMsg::WidthReset(id) => { + // Only docked pallets have the divider that sends this. + if let Some((side, gi)) = self.dock.location(id) { + self.dock.reset_group_width(side, gi); + self.save_config(); + } + } + DockMsg::TitleHover(id) => self.dock_title_hover = Some(id), + DockMsg::TitleHoverEnd(id) => { + if self.dock_title_hover == Some(id) { + self.dock_title_hover = None; + } + } + DockMsg::GripHover(side, gi) => self.dock_grip_hover = Some((side, gi)), + DockMsg::GripHoverEnd(side, gi) => { + if self.dock_grip_hover == Some((side, gi)) { + self.dock_grip_hover = None; + } + } + DockMsg::FloatRaise(id) => self.dock.raise_float(id), + DockMsg::FloatOut(id) => { + // Float beside the edge it was docked on, near the top. + let (ww, _) = self.dock_workspace_size(); + let (w, h) = self.dock_float_size(id); + let strip = crate::ui::dock::DOCK_STRIP_W; + let x = match self.dock.location(id) { + Some((DockSide::Right, _)) => { + ww - strip - self.dock_column_width(DockSide::Right) - w - 60.0 + } + _ => strip + self.dock_column_width(DockSide::Left) + 40.0, + }; + self.dock_drag = None; + if self.dock.float(FloatPanel::new(id, x.max(0.0), 40.0, w, h)) { + self.save_config(); + } + } + DockMsg::DockTo(id, side) => { + self.dock_drag = None; + self.dock_float_menu = None; + if self.dock.dock(id, side, usize::MAX) { + self.save_config(); + } + } + DockMsg::FloatMenu(id) => { + self.dock_float_menu = id; + self.dock_float_menu_at = self.dock_float_pointer; + if let Some(id) = id { + self.dock.raise_float(id); + } + } + DockMsg::FloatBarPointer(p) => self.dock_float_pointer = p, + DockMsg::FloatDockingToggle(id) => { + self.dock_float_menu = None; + if self.dock.toggle_float_docking(id) { + self.save_config(); + } + } + DockMsg::EdgeMenu(side) => self.dock_edge_menu = side, + DockMsg::EdgeMenuToggle(side, id) => { + // Checked = open and docked on this edge: unchecking hides it. + // Anything else (closed, floating, on the other edge) moves + // here and opens, in its own group here if it has one, else + // as a new group; adding a pallet ends the menu's job. + let here = self.dock.location(id).map(|(s, _)| s) == Some(side); + if !here { + self.dock.dock(id, side, usize::MAX); + } else if self.dock_panel_visible(id) { + let task = self.dock_set_open(id, false); + self.save_config(); + return task; + } + self.dock_edge_menu = None; + let task = self.dock_set_open(id, true); + self.save_config(); + return task; + } + DockMsg::AutoCollapseToggle(id) => { + // Docked: auto-hide for the whole edge; floating: this pallet. + if self.dock.toggle_auto_hide(id) { + self.dock_peek = None; + } + self.save_config(); + } + DockMsg::Close(id) => return self.dock_set_open(id, false), + DockMsg::Hover(_) + | DockMsg::HoverEnd(_) + | DockMsg::HoverExit + | DockMsg::HoverStay + | DockMsg::HoverSettled(_) => return self.dock_hover(m), + DockMsg::DragMove(point) => self.dock_drag_move(point), + DockMsg::DragRelease => self.dock_drag_release(), + } + iced::Task::none() + } + + /// Pointer entering / leaving strip icons, edge columns and floating + /// frames, and the delayed auto-hide reveal / hide that follows. + fn dock_hover(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { + use crate::ui::dock::{DockMsg, HoverPending}; + match m { + DockMsg::Hover(id) => { + self.dock_icon_hover = Some(id); + // Only an auto-hiding edge (or floating pallet) reacts to + // hover: after a short rest it reveals the hovered pallet's + // group. Otherwise groups switch on click. Ignored mid-drag, + // when the pointer is over the preview rather than the strip. + if self.dock_drag.is_none() && self.dock.auto_hides(id) { + if self.dock_peek == Some(id) { + self.dock_hover_cancel(); + } else { + return self.dock_hover_arm(HoverPending::Reveal(id)); + } + } + } + DockMsg::HoverEnd(id) => { + if self.dock_icon_hover == Some(id) { + self.dock_icon_hover = None; + } + } + DockMsg::HoverExit => { + if self.dock_drag.is_none() + && self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) + { + return self.dock_hover_arm(HoverPending::Hide); + } + // Left before a pending reveal ran out: it never happens. + self.dock_hover_cancel(); + } + DockMsg::HoverStay => { + if self.dock_hover_pending == Some(HoverPending::Hide) { + self.dock_hover_cancel(); + } + } + DockMsg::HoverSettled(gen) => { + if gen == self.dock_hover_gen && self.dock_drag.is_none() { + match self.dock_hover_pending.take() { + Some(HoverPending::Reveal(id)) if self.dock.auto_hides(id) => { + self.dock_reveal(id) + } + Some(HoverPending::Hide) + if self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) => + { + self.dock_peek = None; + } + _ => {} + } + } + } + _ => {} + } + iced::Task::none() + } + + /// Pointer released: land what was dragged and save a changed layout. + fn dock_drag_release(&mut self) { + use crate::ui::dock::{DockDrag, DropTarget, FloatPanel}; + let mut changed = false; + match self.dock_drag.take() { + Some(DockDrag::Move { + group: Some((side, gi)), + target: Some(DropTarget::Edge { side: to, index }), + .. + }) => changed = self.dock.move_group(side, gi, to, index), + Some(DockDrag::Move { + panel, + group: None, + target: Some(target), + .. + }) => { + changed = match target { + DropTarget::Edge { side, index } => self.dock.dock(panel, side, index), + DropTarget::Join { side, group, index } => { + self.dock.join_group(panel, side, group, index) + } + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(panel); + // A floating panel keeps its docking choice. + let docking = self.dock.float_rect(panel).is_none_or(|f| f.docking); + self.dock.float(FloatPanel { + docking, + ..FloatPanel::new(panel, x, y, w, h) + }) + } + }; + } + Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {} + Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => { + changed = true; + } + } + if changed { + self.save_config(); + } + self.dock_drag_last = None; + self.xref_col_drag = None; + self.xref_col_last = None; + self.xref_split_drag = false; + } + + /// Pointer motion while something in the dock is dragged. + fn dock_drag_move(&mut self, point: iced::Point) { + // NOTE: xref column drags intentionally do NOT ride this path: its + // points live in workspace space while the header tracker reports + // header-local points, and mixing the two produced a one-time jump + // plus a stuck drag. Column moves arrive via Message::XrefColMove + // only. + use crate::app::config::DockSide; + use crate::ui::dock::{DockDrag, DropTarget}; + let last = self.dock_drag_last.replace(point); + let Some(drag) = self.dock_drag else { + return; + }; + match drag { + DockDrag::Move { + panel, + group, + origin, + target, + grab, + } => { + let origin = origin.unwrap_or(point); + let moved = (point.x - origin.x).hypot(point.y - origin.y); + let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD; + // Hold a floating panel where it was grabbed; a docked one by + // its title bar. + let grab = if target.is_none() && started { + match self.dock.float_rect(panel) { + Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y), + None => iced::Vector::new( + (self.dock.width(panel, self.win_size.0) * 0.5).min(120.0), + 14.0, + ), + } + } else { + grab + }; + let target = if started { + // A floating panel that may not dock only floats. + let found = match self.dock.float_rect(panel) { + Some(f) if !f.docking => self.dock_float_target(point, grab), + _ => self.dock_drop_target(point, grab), + }; + // A whole group only moves between group positions: it + // cannot join another group or float. + match (group, found) { + (Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found), + (Some(_), _) => None, + } + } else { + target + }; + self.dock_drag = Some(DockDrag::Move { + panel, + group, + origin: Some(origin), + target, + grab, + }); + } + DockDrag::Split { + side, + group, + upper, + lower, + } => { + let Some(last) = last else { return }; + let Some(weights) = self.dock.groups(side).get(group).map(|g| &g.weights) else { + return; + }; + let (_, avail) = self.dock_workspace_size(); + let total: f32 = self + .dock_slot_spans(side) + .iter() + .filter_map(|(i, _, _)| weights.get(*i)) + .sum(); + if avail > 0.0 && total > 0.0 { + let per_px = total / avail; + self.dock.shift_split( + side, + group, + upper, + lower, + (point.y - last.y) * per_px, + crate::ui::dock::GROUP_MIN_H * per_px, + ); + } + } + DockDrag::FloatSize { panel, from_left } => { + let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else { + return; + }; + let dx = point.x - last.x; + self.dock.resize_float( + panel, + if from_left { f.w - dx } else { f.w + dx }, + f.h + point.y - last.y, + from_left, + ); + } + DockDrag::LayerColumn => { + let Some(last) = last else { return }; + self.layer_name_col_w = + (self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0); + } + DockDrag::Width(id) => { + let Some(last) = last else { return }; + // The divider sizes the docked pallet's group. + if let Some((side, gi)) = self.dock.location(id) { + let dx = point.x - last.x; + let delta = if side == DockSide::Left { dx } else { -dx }; + let cur = self.dock_group_width(side, gi) + delta; + self.dock.set_group_width(side, gi, cur); + } + } + } + } + + /// Start `drag`, peeking `peek` (a pallet being dragged or resized stays + /// shown on an auto-hiding edge). + fn dock_begin( + &mut self, + drag: crate::ui::dock::DockDrag, + peek: Option, + ) { + if let crate::ui::dock::DockDrag::Move { panel, .. } = drag { + self.dock.raise_float(panel); + } + self.dock_drag = Some(drag); + self.dock_drag_last = None; + self.dock_edge_menu = None; + self.dock_float_menu = None; + if peek.is_some() { + self.dock_peek = peek; + } + } + + /// Arm a delayed reveal or hide; it applies when its timer reports back + /// unless a newer hover superseded it. + fn dock_hover_arm(&mut self, target: crate::ui::dock::HoverPending) -> iced::Task { + self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1); + self.dock_hover_pending = Some(target); + let gen = self.dock_hover_gen; + iced::Task::perform(hover_delay(), move |()| { + Message::Dock(crate::ui::dock::DockMsg::HoverSettled(gen)) + }) + } + + /// Drop any pending delayed reveal / hide. + fn dock_hover_cancel(&mut self) { + self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1); + self.dock_hover_pending = None; + } + + /// Bring `id` into view: show its group on its edge and, where it + /// auto-hides, keep it revealed until the pointer leaves. + pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) { + self.dock.show_group_of(id); + self.dock_peek = Some(id); + } + + /// Open or close pallet `id`, running its usual open path (refreshes, + /// ribbon highlight) or close path. An opened pallet is revealed where + /// the layout has it. + pub(crate) fn dock_set_open( + &mut self, + id: crate::ui::dock::PanelId, + open: bool, + ) -> iced::Task { + use crate::ui::dock::{DockDrag, PanelId}; + let mut task = iced::Task::none(); + if open { + if !self.dock_panel_visible(id) { + match id { + PanelId::Properties => { + self.show_properties = true; + self.ribbon.set_properties(true); + } + PanelId::BlockPalette => self.open_blocks_palette(None), + PanelId::ExternalReferences => { + self.show_external_references = true; + self.refresh_xref_manager(); + } + PanelId::Browser => self.show_browser = true, + PanelId::NodeGraph => { + task = self.on_graph(crate::ui::node_graph::GraphMsg::Toggle); + } + PanelId::PointCloudManager => self.pc_manager.show = true, + PanelId::Count => self.set_count_palette(true), + PanelId::SheetSetManager => self.show_sheet_set_manager(true), + PanelId::Layers => { + self.sync_ribbon_layers(); + self.show_layers = true; + } + } + } + self.dock_reveal(id); + return task; + } + match id { + PanelId::BlockPalette => { + self.show_block_palette = false; + self.block_palette.placing = None; + } + PanelId::ExternalReferences => self.show_external_references = false, + PanelId::Browser => self.show_browser = false, + PanelId::NodeGraph => self.show_node_graph = false, + PanelId::PointCloudManager => self.pc_manager.show = false, + PanelId::Count => { + self.count_palette.show = false; + self.ribbon.set_count_palette(false); + } + PanelId::SheetSetManager => { + self.sheet_set.show = false; + self.ribbon.set_sheet_set(false); + } + PanelId::Properties => { + self.show_properties = false; + self.ribbon.set_properties(false); + } + PanelId::Layers => { + self.show_layers = false; + self.ribbon.deactivate_tool_if("LAYERS"); + } + } + self.dock_unpeek(id); + // A closed pallet cannot stay held by the pointer. + let held = match self.dock_drag { + Some(DockDrag::Move { panel, .. } | DockDrag::Width(panel)) => panel == id, + _ => false, + }; + if held { + self.dock_drag = None; + } + task + } + + /// Show `id` where the layout has it, first docking it on the right edge + /// when it has no place yet (opened for the first time, or never moved). + pub(crate) fn dock_open_at_default(&mut self, id: crate::ui::dock::PanelId) { + if !self.dock.is_placed(id) { + self.dock + .dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + self.dock_reveal(id); + } + + /// Open the Layer Manager pallet (LAYERS). The first time it floats over + /// the middle of the drawing, where the dialog it replaced used to open; + /// after that it opens wherever the user left it. + pub(crate) fn open_layers_pallet(&mut self) { + use crate::ui::dock::{FloatPanel, PanelId}; + self.sync_ribbon_layers(); + self.show_layers = true; + if !self.dock.is_placed(PanelId::Layers) { + let (ww, wh) = self.dock_workspace_size(); + let (w, h) = self.dock_float_size(PanelId::Layers); + let (x, y) = (((ww - w) * 0.5).max(0.0), ((wh - h) * 0.5).max(0.0)); + self.dock + .float(FloatPanel::new(PanelId::Layers, x, y, w, h)); + self.save_config(); + } + self.dock.raise_float(PanelId::Layers); + self.dock_reveal(PanelId::Layers); + } + + /// Stop revealing `id` (it closed). + pub(crate) fn dock_unpeek(&mut self, id: crate::ui::dock::PanelId) { + if self.dock_peek == Some(id) { + self.dock_peek = None; + } + } + + /// Whether `id` is currently rendered (not closed, not on the start screen / + /// clean-screen viewport). Mirrors the visibility filter the edge column + /// renderer applies before building each side's stack. + pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool { + use crate::ui::dock::PanelId; + if self.tabs[self.active_tab].is_start || self.clean_screen { + return false; + } + match id { + PanelId::Properties => self.show_properties, + PanelId::BlockPalette => self.show_block_palette, + PanelId::ExternalReferences => self.show_external_references, + PanelId::Browser => self.show_browser, + PanelId::NodeGraph => self.show_node_graph, + PanelId::PointCloudManager => self.pc_manager.show, + PanelId::Count => self.count_palette.show, + PanelId::SheetSetManager => self.sheet_set.show, + PanelId::Layers => self.show_layers, + } + } + + /// Size of the workspace the dock lays out in: window width by viewport + /// height. + pub(crate) fn dock_workspace_size(&self) -> (f32, f32) { + ( + self.win_size.0, + self.tabs[self.active_tab] + .scene + .selection + .borrow() + .view + .vp_size + .1, + ) + } + + /// The open pallets of group `gi` on `side`, top → bottom. Closed + /// pallets keep their place but take no space, so a group with none open + /// is not drawn at all. + pub(crate) fn dock_group_visible( + &self, + side: crate::app::config::DockSide, + gi: usize, + ) -> Vec { + // A stale index (a message from before the layout changed) reads as + // an empty group rather than a crash. + self.dock + .groups(side) + .get(gi) + .into_iter() + .flat_map(|g| g.panels.iter().copied()) + .filter(|id| self.dock_panel_visible(*id)) + .collect() + } + + /// Indices of the groups on `side` with an open pallet (listed in the + /// icon strip). + pub(crate) fn dock_visible_groups(&self, side: crate::app::config::DockSide) -> Vec { + (0..self.dock.groups(side).len()) + .filter(|gi| !self.dock_group_visible(side, *gi).is_empty()) + .collect() + } + + /// The group `side` draws: the one it shows, or the first with an open + /// pallet when that one has none. + pub(crate) fn dock_shown_group(&self, side: crate::app::config::DockSide) -> Option { + let visible = self.dock_visible_groups(side); + let shown = self.dock.shown(side); + if visible.contains(&shown) { + Some(shown) + } else { + visible.first().copied() + } + } + + /// Whether `side` draws its shown group beside the icon strip: always, + /// unless the edge auto-hides and none of the group's icons is hovered. + pub(crate) fn dock_edge_expanded(&self, side: crate::app::config::DockSide) -> bool { + let Some(gi) = self.dock_shown_group(side) else { + return false; + }; + !self.dock.edge_auto_hide(side) + || self + .dock_peek + .is_some_and(|e| self.dock_group_visible(side, gi).contains(&e)) + } + + /// On-screen width of group `gi` on `side`. + pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 { + self.dock.group_width_px(side, gi, self.win_size.0) + } + + /// Width of the pallet column beside `side`'s icon strip: the shown + /// group's width, or nothing when it is hidden. + pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 { + match self.dock_shown_group(side) { + Some(gi) if self.dock_edge_expanded(side) => self.dock_group_width(side, gi), + _ => 0.0, + } + } + + /// The icon strip of `side`: each listed group's place, and the top of + /// the + button. + pub(crate) fn dock_strip_layout( + &self, + side: crate::app::config::DockSide, + ) -> (Vec, f32) { + let groups: Vec<(usize, usize)> = self + .dock_visible_groups(side) + .into_iter() + .map(|gi| (gi, self.dock_group_visible(side, gi).len())) + .collect(); + crate::ui::dock::strip_layout(&groups) + } + + /// The open pallets of `side`'s shown group, each as (position in the + /// group, top, bottom) in the workspace. + pub(crate) fn dock_slot_spans( + &self, + side: crate::app::config::DockSide, + ) -> Vec<(usize, f32, f32)> { + match self.dock_shown_group(side) { + Some(gi) => self.dock_open_spans(&self.dock.groups(side)[gi], None), + None => Vec::new(), + } + } + + /// The open pallets of `group` (plus `extra`, a pallet about to land + /// there), each as (position in the group, top, bottom) down the + /// workspace height. + pub(crate) fn dock_open_spans( + &self, + group: &crate::ui::dock::DockGroup, + extra: Option, + ) -> Vec<(usize, f32, f32)> { + let (_, avail) = self.dock_workspace_size(); + let open: Vec = (0..group.panels.len()) + .filter(|i| { + let id = group.panels[*i]; + Some(id) == extra || self.dock_panel_visible(id) + }) + .collect(); + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + open.into_iter() + .zip(crate::ui::dock::slot_spans(&weights, avail)) + .map(|(i, (top, bottom))| (i, top, bottom)) + .collect() + } + + /// Size a panel takes when floated: its current floating size, or its + /// docked width by a comfortable share of the workspace height. + pub(crate) fn dock_float_size(&self, id: crate::ui::dock::PanelId) -> (f32, f32) { + if let Some(f) = self.dock.float_rect(id) { + return (f.w, f.h); + } + let (_, avail) = self.dock_workspace_size(); + ( + self.dock.width(id, self.win_size.0), + (avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0), + ) + } + + /// Where a dragged pallet lands if released at workspace point `p`: + /// on an edge's icon strip it joins a group between its icons or starts a + /// new group; over the shown group's pallets it stacks above / below the + /// one under the pointer; on an empty edge it starts a group; anywhere + /// else it floats, held at `grab` from its top-left corner. + pub(crate) fn dock_drop_target( + &self, + p: iced::Point, + grab: iced::Vector, + ) -> crate::ui::dock::DropTarget { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W}; + let (ww, _) = self.dock_workspace_size(); + for side in [DockSide::Left, DockSide::Right] { + let listed = !self.dock_visible_groups(side).is_empty(); + let from_edge = match side { + DockSide::Left => p.x, + DockSide::Right => ww - p.x, + }; + if !listed { + if from_edge <= DOCK_EDGE_ZONE { + return DropTarget::Edge { + side, + index: self.dock.groups(side).len(), + }; + } + continue; + } + // Over the icon strip. + if from_edge <= DOCK_STRIP_W { + let (layout, _) = self.dock_strip_layout(side); + return match crate::ui::dock::strip_hit(p.y, &layout) { + StripHit::NewGroup(at) => DropTarget::Edge { + side, + index: at.unwrap_or(self.dock.groups(side).len()), + }, + StripHit::Join { group, index } => { + // Map the gap among open pallets to a position among + // all of the group's pallets. + let open = self.dock_group_visible(side, group); + let all = &self.dock.groups(side)[group].panels; + let index = open + .get(index) + .and_then(|t| all.iter().position(|a| a == t)) + .unwrap_or(all.len()); + DropTarget::Join { side, group, index } + } + }; + } + // Over the shown group's pallets: above or below the one there. + if from_edge <= DOCK_STRIP_W + self.dock_column_width(side) { + let gi = self.dock_shown_group(side).expect("listed edge"); + let spans = self.dock_slot_spans(side); + if let Some(&(pos, top, bottom)) = spans + .iter() + .find(|(_, _, bottom)| p.y < *bottom) + .or(spans.last()) + { + let index = if p.y < (top + bottom) * 0.5 { + pos + } else { + pos + 1 + }; + return DropTarget::Join { + side, + group: gi, + index, + }; + } + } + } + self.dock_float_target(p, grab) + } + + /// Floating at workspace point `p`, held at `grab` from its top-left + /// corner (kept on the workspace). + pub(crate) fn dock_float_target( + &self, + p: iced::Point, + grab: iced::Vector, + ) -> crate::ui::dock::DropTarget { + let (ww, wh) = self.dock_workspace_size(); + let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0)); + let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0)); + crate::ui::dock::DropTarget::Float { x, y } + } +} diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs new file mode 100644 index 000000000..f1d4d3ff6 --- /dev/null +++ b/src/app/dock/view.rs @@ -0,0 +1,1183 @@ +//! Dock drawing: the edges (icon strip and shown group), floating pallets, +//! the + menu and the drag preview. + +use crate::app::document::DocumentTab; +use crate::app::{Message, OpenCADStudio}; +use crate::t; +use crate::ui::dock::DOCK_DIVIDER_W; +use iced::widget::{button, canvas, column, container, mouse_area, row, stack, text, Space}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +impl OpenCADStudio { + /// Everything the dock draws over the workspace (the row of edges and + /// viewport): floating pallets, an open + menu, the drag preview, and + /// the pointer capture that feeds drags. + pub(in crate::app) fn dock_overlay<'a>( + &'a self, + workspace: Element<'a, Message>, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + // Floating panels sit over the workspace, back to front. + let mut layers: Vec> = vec![workspace]; + for f in &self.dock.floating { + // Once a drag is under way only its preview shows the panel. + let dragged = self + .dock_drag + .and_then(|d| d.movement()) + .is_some_and(|(panel, _, target)| panel == f.id && target.is_some()); + if self.dock_panel_visible(f.id) && !dragged { + layers.push(self.floating_panel(*f, tab)); + } + } + // An open pallet menu hangs beside its edge's + button, over a + // catcher that closes it on any click elsewhere. + if let Some(side) = self.dock_edge_menu { + if !self.dock_visible_groups(side).is_empty() { + // Checked = open and docked on this edge. + let checked: Vec<_> = crate::ui::dock::PanelId::ALL + .iter() + .map(|p| { + let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); + (*p, here && self.dock_panel_visible(*p)) + }) + .collect(); + let (_, plus_top) = self.dock_strip_layout(side); + let strip = crate::ui::dock::DOCK_STRIP_W; + let x = match side { + crate::app::config::DockSide::Left => strip + 4.0, + crate::app::config::DockSide::Right => { + self.dock_workspace_size().0 - strip - DOCK_MENU_W - 4.0 + } + }; + layers.push( + mouse_area(Space::new().width(Fill).height(Fill)) + .on_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) + .into(), + ); + layers.push(place_at( + dock_edge_menu(side, &checked), + x.max(0.0), + plus_top, + )); + } + } + // An open floating title-bar menu, over its own click catcher. + if let Some(menu) = self.dock_float_menu_overlay() { + let close = Message::Dock(crate::ui::dock::DockMsg::FloatMenu(None)); + layers.push( + mouse_area(Space::new().width(Fill).height(Fill)) + .on_press(close.clone()) + .on_right_press(close) + .into(), + ); + layers.push(menu); + } + // While a panel is dragged, preview where it lands. + if let Some((id, _, Some(target))) = self.dock_drag.and_then(|d| d.movement()) { + layers.push(self.dock_drop_preview(id, target)); + } + // Always a stack (and, below, always a mouse area), even with nothing + // on top: iced keeps a widget's state only while the tree keeps its + // shape, and a title bar's double-click needs its first click to + // survive the drag that pressing the bar starts. + let workspace: Element<'_, Message> = iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into(); + use crate::ui::dock::DockDrag; + let capturing = + self.dock_drag.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag; + let mut capture = mouse_area(workspace); + if capturing { + capture = capture + .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) + .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) + .interaction(match self.dock_drag { + Some(DockDrag::FloatSize { + from_left: true, .. + }) => iced::mouse::Interaction::ResizingDiagonallyUp, + Some(DockDrag::FloatSize { + from_left: false, .. + }) => iced::mouse::Interaction::ResizingDiagonallyDown, + Some(DockDrag::Split { .. }) => iced::mouse::Interaction::ResizingVertically, + Some(DockDrag::Move { .. }) => iced::mouse::Interaction::Grabbing, + // Widths, the Layer Manager column and the xref drags. + _ => iced::mouse::Interaction::ResizingHorizontally, + }); + } + capture.into() + } + + /// Build one edge: the vertical icon strip at the very edge plus the + /// edge's shown group beside it. The strip lists every group as a grip + /// bar over its pallets' icons, with a + button below; clicking an icon + /// shows its group. The shown group's pallets stack top to bottom, each + /// at its weighted share of the height, with a draggable splitter between + /// neighbours. A group whose pallets all auto-hide shows only while one + /// of its icons (or the group itself) is hovered; leaving the edge hides + /// it again. `None` when nothing on the edge is open. + pub(in crate::app) fn build_edge_stack<'a>( + &'a self, + side: crate::app::config::DockSide, + tab: &'a DocumentTab, + ) -> Option> { + let gi = self.dock_shown_group(side)?; + let strip = self.dock_icon_strip(side); + let column_el = self.dock_edge_expanded(side).then(|| { + let width = self.dock_group_width(side, gi); + let group = &self.dock.groups(side)[gi]; + let mut layers: Vec> = Vec::new(); + let mut prev: Option = None; + for (pos, id) in group.panels.iter().enumerate() { + if !self.dock_panel_visible(*id) { + continue; + } + if let Some(upper) = prev { + layers.push(dock_splitter(side, gi, upper, pos, width)); + } + layers.push( + container(self.expanded_panel(*id, side, width, tab)) + .height(Length::FillPortion(crate::ui::dock::portion( + group.weights[pos], + ))) + .clip(true) + .into(), + ); + prev = Some(pos); + } + column(layers).height(Fill).into() + }); + + let mut children: Vec> = Vec::new(); + match side { + crate::app::config::DockSide::Left => { + children.push(strip); + children.extend(column_el); + } + crate::app::config::DockSide::Right => { + children.extend(column_el); + children.push(strip); + } + } + Some( + mouse_area(row(children).height(Fill)) + .on_enter(Message::Dock(crate::ui::dock::DockMsg::HoverStay)) + .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) + .into(), + ) + } + + /// An edge's vertical icon strip: per group its open pallets' icons in a + /// row ended by a hairline divider, then a + button opening the pallet + /// menu. The band along a group's window-side edge is its grip: it + /// carries the blue bar of the shown group, lights up while hovered (or + /// while it is a drop target) and drags the whole group; an icon shows its + /// group on click and drags just that pallet. Heights follow + /// `dock::strip_layout`, which drop targeting uses too. + fn dock_icon_strip(&self, side: crate::app::config::DockSide) -> Element<'_, Message> { + use crate::app::config::DockSide; + use crate::ui::dock::{ + DockMsg, DropTarget, DOCK_STRIP_W, STRIP_CELL_H, STRIP_DIVIDER_H, STRIP_GRIP_W, + STRIP_PAD, STRIP_PLUS_GAP, + }; + // Only a group actually on screen is marked as shown: on an + // auto-hiding edge nothing is, until a group is revealed. + let shown = self + .dock_shown_group(side) + .filter(|_| self.dock_edge_expanded(side)); + let movement = self.dock_drag.and_then(|d| d.movement()); + let move_target = movement.and_then(|(_, _, target)| target); + let dragging = move_target.is_some(); + let target_group = match move_target { + Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), + _ => None, + }; + let mut items: Vec> = Vec::new(); + for gi in self.dock_visible_groups(side) { + let is_shown = shown == Some(gi); + let lit = + target_group == Some(gi) || (!dragging && self.dock_grip_hover == Some((side, gi))); + let group_dragged = + dragging && movement.is_some_and(|(_, group, _)| group == Some((side, gi))); + let panels = self.dock_group_visible(side, gi); + let row_h = 2.0 * STRIP_PAD + panels.len() as f32 * STRIP_CELL_H; + + let mut icons: Vec> = vec![Space::new().height(STRIP_PAD).into()]; + for id in panels { + let faded = group_dragged + || (dragging + && movement + .is_some_and(|(panel, group, _)| group.is_none() && panel == id)); + let glyph = if faded { + crate::ui::icons::themed_disabled(id.icon(), 20.0) + } else if is_shown { + crate::ui::icons::themed(id.icon(), 20.0) + } else { + crate::ui::icons::themed_secondary(id.icon(), 20.0) + }; + let hovered = !dragging && self.dock_icon_hover == Some(id); + let cell = container(glyph) + .center_x(Length::Fixed(STRIP_CELL_H)) + .center_y(Length::Fixed(STRIP_CELL_H - 2.0)) + .style(move |theme: &Theme| container::Style { + background: hovered + .then(|| Background::Color(theme.palette().background.weak.color)), + border: Border { + radius: 5.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let cell = mouse_area( + container(cell) + .center_x(Length::Fixed(DOCK_STRIP_W)) + .center_y(Length::Fixed(STRIP_CELL_H)), + ) + .on_press(Message::Dock(DockMsg::IconPress(id))) + .on_enter(Message::Dock(DockMsg::Hover(id))) + .on_exit(Message::Dock(DockMsg::HoverEnd(id))) + .interaction(iced::mouse::Interaction::Pointer); + icons.push(cell.into()); + } + + // The group grip: a band on the window side holding the edge bar. + let bar_w = if lit { 5.0 } else { 3.0 }; + let bar_inset = 5.0; + let bar = container( + Space::new() + .width(bar_w) + .height(Length::Fixed(row_h - 2.0 * bar_inset)), + ) + .style(move |theme: &Theme| { + let palette = theme.palette(); + let (color, glow) = if lit { + (Some(palette.primary.weak.color), true) + } else if is_shown { + (Some(palette.primary.base.color), false) + } else { + (None, false) + }; + container::Style { + background: color.map(Background::Color), + border: Border { + radius: match side { + DockSide::Left => iced::border::Radius::default().right(3.0), + DockSide::Right => iced::border::Radius::default().left(3.0), + }, + ..Default::default() + }, + shadow: if glow { + iced::Shadow { + color: palette.primary.base.color.scale_alpha(0.55), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 8.0, + } + } else { + iced::Shadow::default() + }, + ..Default::default() + } + }); + let band = mouse_area( + container(bar) + .width(Length::Fixed(STRIP_GRIP_W)) + .height(Length::Fixed(row_h)) + .align_y(iced::alignment::Vertical::Center) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }), + ) + .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) + .on_enter(Message::Dock(DockMsg::GripHover(side, gi))) + .on_exit(Message::Dock(DockMsg::GripHoverEnd(side, gi))) + .interaction(iced::mouse::Interaction::Grab); + let band = container(band) + .width(Fill) + .height(Length::Fixed(row_h)) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }); + items.push( + // A stack is as tall as its first layer, so the icon column + // spans the whole row; the edge bar then centres on the row. + container(stack![column(icons).height(Length::Fixed(row_h)), band]) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(row_h)) + .style(move |theme: &Theme| container::Style { + background: lit.then(|| { + Background::Color(theme.palette().primary.weak.color.scale_alpha(0.12)) + }), + ..Default::default() + }) + .into(), + ); + items.push( + container(Space::new()) + .width(Fill) + .height(Length::Fixed(STRIP_DIVIDER_H)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.neutral.color, + )), + ..Default::default() + }) + .into(), + ); + } + items.push(Space::new().height(STRIP_PLUS_GAP).into()); + let plus = button(crate::ui::icons::themed_secondary( + crate::ui::icons::PLUS, + 14.0, + )) + .on_press(Message::Dock(DockMsg::EdgeMenu(Some(side)))) + .style(|theme: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => { + theme.palette().background.weak.color + } + _ => theme.palette().background.weakest.color, + })), + border: Border { + radius: 14.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .width(Length::Fixed(28.0)) + .height(Length::Fixed(28.0)) + .padding(7); + items.push(container(plus).center_x(Length::Fixed(DOCK_STRIP_W)).into()); + container(column(items)) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() + } + + /// The content of panel `id` at `width`. A `floating` panel leaves its + /// title to the floating frame's vertical title bar. + pub(in crate::app) fn panel_body<'a>( + &'a self, + id: crate::ui::dock::PanelId, + width: f32, + floating: bool, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + let chrome = crate::ui::dock::Chrome { + auto_collapse: self.dock.auto_hides(id), + floating, + title_hovered: self.dock_title_hover == Some(id), + }; + match id { + crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), + crate::ui::dock::PanelId::BlockPalette => { + crate::ui::window::block_palette::view(&self.block_palette, width, chrome) + } + crate::ui::dock::PanelId::ExternalReferences => { + self.xref_manager + .view(width, chrome, tab.xref_missing, &tab.scene.document) + } + crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( + &tab.scene.document, + tab.sketch_session + .as_ref() + .map(|session| session.name.as_str()), + width, + chrome, + ), + crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, chrome), + crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( + &self.count_palette, + tab.count.as_ref(), + &tab.scene.document, + tab.id, + tab.scene.geometry_epoch, + width, + chrome, + ), + crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( + &self.pc_manager, + &tab.scene.document, + &tab.scene.selected_handles_in_order(), + width, + chrome, + ), + crate::ui::dock::PanelId::SheetSetManager => { + crate::ui::window::sheet_set::view(&self.sheet_set, width, chrome) + } + crate::ui::dock::PanelId::Layers => { + tab.layers.view_panel(self.layer_name_col_w, width, chrome) + } + } + } + + /// A pallet of the shown group: its body plus a grabbable divider + /// against the viewport (sizing the whole group). Hovering is handled by + /// the enclosing edge region (see `build_edge_stack`), so the body stays + /// fully interactive without fighting the region's hover tracking. + fn expanded_panel<'a>( + &'a self, + id: crate::ui::dock::PanelId, + side: crate::app::config::DockSide, + width: f32, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + let panel = self.panel_body(id, width, false, tab); + let divider = dock_divider(id); + match side { + crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), + crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(), + } + } + + /// A floating panel at its saved place: a vertical title bar on the side + /// facing the nearer workspace edge, the panel beside it, and a corner + /// grip to resize it. With its pin on, the panel hides down to the title + /// bar until hovered. + fn floating_panel<'a>( + &'a self, + f: crate::ui::dock::FloatPanel, + tab: &'a DocumentTab, + ) -> Element<'a, Message> { + use crate::ui::dock::DockMsg; + let FloatFrame { + x, + y, + bar_left, + hidden, + } = self.dock_float_frame(f); + let hovered = self.dock_title_hover == Some(f.id); + let bar = floating_title_bar(f.id, self.dock.auto_collapse(f.id), bar_left, hovered); + let content: Element<'_, Message> = if hidden { + bar + } else { + // The resize grip sits in the bottom corner away from the bar. + let grip_left = !bar_left; + let grip_icon = crate::ui::icons::themed_secondary( + if grip_left { + crate::ui::icons::RESIZE_LEFT + } else { + crate::ui::icons::RESIZE + }, + 12.0, + ); + let grip = mouse_area( + container(grip_icon) + .center_x(Length::Fixed(14.0)) + .center_y(Length::Fixed(14.0)), + ) + .on_press(Message::Dock(DockMsg::FloatResizeGrab(f.id, grip_left))) + .interaction(if grip_left { + iced::mouse::Interaction::ResizingDiagonallyUp + } else { + iced::mouse::Interaction::ResizingDiagonallyDown + }); + let body = stack![ + self.panel_body(f.id, f.w, true, tab), + container(grip) + .width(Fill) + .height(Fill) + .align_x(if grip_left { + iced::alignment::Horizontal::Left + } else { + iced::alignment::Horizontal::Right + }) + .align_y(iced::alignment::Vertical::Bottom), + ] + .width(Length::Fixed(f.w)) + .height(Fill); + if bar_left { + row![bar, body].into() + } else { + row![body, bar].into() + } + }; + // Clipped, so a panel squeezed below its content's height never + // draws outside its frame. + let framed = container(content) + .height(Length::Fixed(f.h)) + .clip(true) + .style(|theme: &Theme| container::Style { + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 0.0.into(), + }, + shadow: iced::Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + offset: iced::Vector::new(0.0, 4.0), + blur_radius: 14.0, + }, + ..Default::default() + }); + // The idle cursor marks the panel as opaque to the pointer, so clicks + // on its empty areas don't fall through to the drawing below. + let panel = mouse_area(framed) + .on_press(Message::Dock(DockMsg::FloatRaise(f.id))) + .on_enter(Message::Dock(DockMsg::Hover(f.id))) + .on_exit(Message::Dock(DockMsg::HoverExit)) + .interaction(iced::mouse::Interaction::Idle); + place_at(panel.into(), x, y) + } + + /// Where floating panel `f` draws. + fn dock_float_frame(&self, f: crate::ui::dock::FloatPanel) -> FloatFrame { + let (ww, wh) = self.dock_workspace_size(); + // The bar faces the nearer workspace edge; while resizing, it stays + // opposite the grip being dragged so it cannot flip mid-drag. + let bar_left = match self.dock_drag { + Some(crate::ui::dock::DockDrag::FloatSize { panel, from_left }) if panel == f.id => { + !from_left + } + _ => f.x + (f.w + DOCK_FLOAT_BAR_W) * 0.5 < ww * 0.5, + }; + let hidden = self.dock.auto_collapse(f.id) && self.dock_peek != Some(f.id); + // A hidden panel keeps its title bar where it sits when shown. + let x = if hidden && !bar_left { f.x + f.w } else { f.x }; + let frame = FloatFrame { + x, + y: f.y, + bar_left, + hidden, + }; + // Keep the panel on screen when the window shrank since it was placed. + FloatFrame { + x: x.min(ww - frame.width(f)).max(0.0), + y: f.y.min(wh - f.h).max(0.0), + ..frame + } + } + + /// The open right-click menu of a floating title bar: Allow docking, + /// Dock left and Dock right. It opens at the pointer, flipped to stay on + /// screen. + fn dock_float_menu_overlay(&self) -> Option> { + use crate::app::config::DockSide; + use crate::ui::dock::DockMsg; + let id = self.dock_float_menu?; + let f = self.dock.float_rect(id)?; + if !self.dock_panel_visible(id) { + return None; + } + let (ww, wh) = self.dock_workspace_size(); + let frame = self.dock_float_frame(f); + // Where the right-click happened, in the workspace. + let at = iced::Point::new( + frame.bar_x(f) + self.dock_float_menu_at.x, + frame.y + self.dock_float_menu_at.y, + ); + let menu_x = if at.x + DOCK_MENU_W <= ww { + at.x + } else { + at.x - DOCK_MENU_W + }; + let menu_x = menu_x.clamp(0.0, (ww - DOCK_MENU_W).max(0.0)); + let menu_y = at.y.min(wh - FLOAT_MENU_H).max(0.0); + let item = + |label: Element<'static, Message>, press: DockMsg| -> Element<'static, Message> { + button(label) + .on_press(Message::Dock(press)) + .style(menu_row_style) + .padding([5, 8]) + .width(Fill) + .into() + }; + let check: Element<'static, Message> = if f.docking { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + let docking = + row![text(t!("Allow docking")).size(12).width(Fill), check].align_y(iced::Center); + let separator = container(Space::new()) + .width(Fill) + .height(Length::Fixed(1.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + let menu = column![ + item(docking.into(), DockMsg::FloatDockingToggle(id)), + container(separator).padding([3, 4]), + item( + text(t!("Dock left")).size(12).into(), + DockMsg::DockTo(id, DockSide::Left) + ), + item( + text(t!("Dock right")).size(12).into(), + DockMsg::DockTo(id, DockSide::Right) + ), + ] + .spacing(1) + .padding(4); + Some(place_at(menu_frame(menu.into()), menu_x, menu_y)) + } + + /// The live drop preview for dragged panel `id`: a tint over the target + /// edge plus a panel-sized ghost where it lands — stacked into a group + /// (sized as that group will be), as a new group of its own (full + /// height), or as a floating window. + fn dock_drop_preview( + &self, + id: crate::ui::dock::PanelId, + target: crate::ui::dock::DropTarget, + ) -> Element<'_, Message> { + use crate::app::config::DockSide; + use crate::ui::dock::DropTarget; + let (ww, _) = self.dock_workspace_size(); + let strip = crate::ui::dock::DOCK_STRIP_W; + let tint = |side: DockSide, w: f32| -> Element<'_, Message> { + let band = container(Space::new()) + .width(Length::Fixed(w + strip)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().primary.weak.color.scale_alpha(0.35), + )), + ..Default::default() + }); + container(band) + .width(Fill) + .height(Fill) + .align_x(match side { + DockSide::Left => iced::alignment::Horizontal::Left, + DockSide::Right => iced::alignment::Horizontal::Right, + }) + .into() + }; + let mut layers: Vec> = Vec::new(); + let group_drag = self + .dock_drag + .and_then(|d| d.movement()) + .and_then(|(_, g, _)| g); + let marks_side = match target { + DropTarget::Edge { side, .. } | DropTarget::Join { side, .. } => Some(side), + DropTarget::Float { x, y } => { + let (w, h) = self.dock_float_size(id); + layers.push(place_at(dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), x, y)); + None + } + }; + // A single pallet also gets a ghost where it lands in the column; a + // whole group shows only the strip feedback, as the column keeps + // showing what is there. + if let (Some(side), None) = (marks_side, group_drag) { + if let Some((top, bottom, w)) = self.dock_landing(id, target) { + let x = match side { + DockSide::Left => strip, + DockSide::Right => (ww - w - strip).max(0.0), + }; + layers.push(tint(side, w)); + layers.push(place_at(dock_ghost(id, w, bottom - top), x, top)); + } + } + // On top: the strip's drop marks and a ghost of what is dragged + // following the pointer. + if let Some(side) = marks_side { + layers.extend(self.dock_strip_drop_marks(side, target)); + if let Some(p) = self.dock_drag_last { + let icons = match group_drag { + Some((s, g)) => self.dock_group_visible(s, g), + None => vec![id], + }; + layers.push(place_at(dock_icon_ghost(&icons), p.x - 16.0, p.y - 15.0)); + } + } + iced::widget::Stack::with_children(layers) + .width(Fill) + .height(Fill) + .into() + } + + /// Where docked pallet `id` would sit after dropping on `target`: its + /// span (top, bottom) in its group as laid out after the drop, and the + /// group's width. `None` for a floating target. + fn dock_landing( + &self, + id: crate::ui::dock::PanelId, + target: crate::ui::dock::DropTarget, + ) -> Option<(f32, f32, f32)> { + use crate::ui::dock::DropTarget; + let mut after = self.dock.clone(); + match target { + DropTarget::Edge { side, index } => { + after.dock(id, side, index); + } + DropTarget::Join { side, group, index } => { + after.join_group(id, side, group, index); + } + DropTarget::Float { .. } => return None, + } + let (side, gi) = after.location(id)?; + let group = &after.groups(side)[gi]; + let (_, top, bottom) = self + .dock_open_spans(group, Some(id)) + .into_iter() + .find(|(i, _, _)| group.panels[*i] == id)?; + Some((top, bottom, after.group_width_px(side, gi, self.win_size.0))) + } + + /// The icon strip's drop marks for `target` on `side`: a short line with + /// end caps between a group's icons (join that group there), or a + /// full-width line on a divider with a "+ New group" badge. + fn dock_strip_drop_marks( + &self, + side: crate::app::config::DockSide, + target: crate::ui::dock::DropTarget, + ) -> Vec> { + use crate::app::config::DockSide; + use crate::ui::dock::{DropTarget, DOCK_STRIP_W, STRIP_CELL_H}; + let (ww, _) = self.dock_workspace_size(); + let strip_x = match side { + DockSide::Left => 0.0, + DockSide::Right => ww - DOCK_STRIP_W, + }; + let (layout, _) = self.dock_strip_layout(side); + let mut marks: Vec> = Vec::new(); + match target { + DropTarget::Join { group, index, .. } => { + let Some(g) = layout.iter().find(|g| g.group == group) else { + return marks; + }; + // Icons above the insertion point, counted as the strip shows + // them (the dragged icon still sits in its old place). + let dragged = self.dock_drag.and_then(|d| d.movement()).map(|(p, _, _)| p); + let above = self.dock.groups(side)[group].panels[..index] + .iter() + .filter(|p| self.dock_panel_visible(**p) || Some(**p) == dragged) + .count(); + let y = g.icons_top + above as f32 * STRIP_CELL_H; + marks.push(place_at(dock_insert_line(), strip_x + 7.0, y - 3.5)); + } + DropTarget::Edge { index, .. } => { + let y = layout + .iter() + .find(|g| g.group >= index) + .map(|g| g.top) + .or_else(|| layout.last().map(|g| g.bottom())) + .unwrap_or(0.0); + marks.push(place_at( + container(Space::new()) + .width(Length::Fixed(DOCK_STRIP_W)) + .height(Length::Fixed(4.0)) + .style(dock_drop_line_style) + .into(), + strip_x, + (y - 2.0).max(0.0), + )); + const BADGE_W: f32 = 96.0; + let badge = container(text(format!("+ {}", t!("New group"))).size(10)) + .width(Length::Fixed(BADGE_W)) + .center_x(Length::Fixed(BADGE_W)) + .padding([2, 6]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + text_color: Some(theme.palette().primary.base.text), + border: Border { + radius: 9.0.into(), + ..Default::default() + }, + ..Default::default() + }); + let badge_x = match side { + DockSide::Left => DOCK_STRIP_W + 6.0, + DockSide::Right => ww - DOCK_STRIP_W - 6.0 - BADGE_W, + }; + marks.push(place_at(badge.into(), badge_x, (y - 9.0).max(0.0))); + } + DropTarget::Float { .. } => {} + } + marks + } +} + +/// The glowing accent used by the strip's drop lines. +fn dock_drop_line_style(theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(theme.palette().primary.base.color)), + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + shadow: iced::Shadow { + color: theme.palette().primary.base.color.scale_alpha(0.8), + offset: iced::Vector::new(0.0, 0.0), + blur_radius: 6.0, + }, + ..Default::default() + } +} + +/// The short insertion line with end caps shown between a group's icons. +fn dock_insert_line() -> Element<'static, Message> { + let cap = || { + container(Space::new()) + .width(Length::Fixed(3.0)) + .height(Length::Fixed(7.0)) + .style(dock_drop_line_style) + }; + row![ + cap(), + container(Space::new()) + .width(Length::Fixed(20.0)) + .height(Length::Fixed(3.0)) + .style(dock_drop_line_style), + cap(), + ] + .align_y(iced::Center) + .height(Length::Fixed(7.0)) + .into() +} + +/// The ghost following the pointer while pallets are dragged: their icons +/// in an accent-framed box. +fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Message> { + let glyphs: Vec> = icons + .iter() + .map(|id| { + container(crate::ui::icons::themed(id.icon(), 18.0)) + .center_x(Length::Fixed(32.0)) + .center_y(Length::Fixed(30.0)) + .into() + }) + .collect(); + container(column(glyphs)) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.primary.base.color.scale_alpha(0.22), + )), + border: Border { + color: palette.primary.base.color, + width: 1.5, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// Canvas that draws a floating title bar's label, reading upward from the +/// bar's bottom end. +struct VBarLabel { + text: String, +} + +impl canvas::Program for VBarLabel { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + theme: &Theme, + bounds: iced::Rectangle, + _cursor: iced::advanced::mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + frame.with_save(|frame| { + frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); + frame.rotate(iced::Radians(-std::f32::consts::FRAC_PI_2)); + // After the rotation the text runs along local +x (upward), so + // the bar's bottom end sits at -height/2. + frame.fill_text(canvas::Text { + content: self.text.clone(), + position: iced::Point::new(-bounds.height / 2.0, 0.0), + color: theme.palette().background.base.text.scale_alpha(0.72), + size: iced::Pixels(13.0), + align_x: iced::advanced::text::Alignment::Left, + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + }); + vec![frame.into_geometry()] + } +} + +/// Width of a floating panel's vertical title bar. +const DOCK_FLOAT_BAR_W: f32 = 28.0; + +/// Where a floating panel draws: its top-left corner, whether its title bar +/// is on the left, and whether it is hidden down to that bar (auto-hide, not +/// hovered). +#[derive(Debug, Clone, Copy)] +struct FloatFrame { + x: f32, + y: f32, + bar_left: bool, + hidden: bool, +} + +impl FloatFrame { + /// On-screen width of panel `f` in this frame: the bar, plus the panel + /// unless hidden. + fn width(&self, f: crate::ui::dock::FloatPanel) -> f32 { + if self.hidden { + DOCK_FLOAT_BAR_W + } else { + f.w + DOCK_FLOAT_BAR_W + } + } + + /// Left edge of the title bar. + fn bar_x(&self, f: crate::ui::dock::FloatPanel) -> f32 { + if self.bar_left { + self.x + } else { + self.x + self.width(f) - DOCK_FLOAT_BAR_W + } + } +} + +/// A floating panel's vertical title bar: close then the hide (auto-collapse) +/// pin at the top, the panel's icon at the bottom with its title reading +/// upward above it. Pressing the bar's free area drags the panel. +fn floating_title_bar( + id: crate::ui::dock::PanelId, + auto_collapse: bool, + on_left: bool, + hovered: bool, +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let tip = if on_left { + iced::widget::tooltip::Position::Left + } else { + iced::widget::tooltip::Position::Right + }; + let title = canvas(VBarLabel { + text: t!(id.title()).into_owned(), + }) + .width(Fill) + .height(Fill); + // Close and pin only show while the pointer is over the bar, as on a + // docked title bar. + let mut bar = column![].spacing(2); + if hovered { + bar = bar + .push(crate::ui::dock::close_button(id, tip)) + .push(crate::ui::dock::pin_button(id, auto_collapse, tip)); + } + let bar = bar + .push(container(title).width(Fill).height(Fill).padding([6, 0])) + .push(container(crate::ui::icons::themed(id.icon(), 16.0)).center_x(Fill)) + .padding([4, 0]) + .align_x(iced::Center) + .width(Length::Fixed(DOCK_FLOAT_BAR_W)) + .height(Fill); + mouse_area(container(bar).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + })) + .on_press(Message::Dock(DockMsg::DockGrab(id))) + .on_right_press(Message::Dock(DockMsg::FloatMenu(Some(id)))) + .on_enter(Message::Dock(DockMsg::TitleHover(id))) + .on_move(|p| Message::Dock(DockMsg::FloatBarPointer(p))) + .on_exit(Message::Dock(DockMsg::TitleHoverEnd(id))) + // Double-click docks it against the edge its title bar faces. + .on_double_click(Message::Dock(DockMsg::DockTo( + id, + if on_left { + crate::app::config::DockSide::Left + } else { + crate::app::config::DockSide::Right + }, + ))) + .interaction(iced::mouse::Interaction::Grab) + .into() +} + +/// Grabbable separator between a docked pallet and the viewport: drag to +/// size its group, double-click to reset the width. +fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { + let line = container(Space::new()) + .width(Length::Fixed(DOCK_DIVIDER_W)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); + let reset = Message::Dock(crate::ui::dock::DockMsg::WidthReset(id)); + mouse_area(line) + .on_press(grab) + .on_double_click(reset) + .interaction(iced::mouse::Interaction::ResizingHorizontally) + .into() +} + +/// Width of the icon strip's pallet menu. +const DOCK_MENU_W: f32 = 230.0; +/// Rough height of a floating title bar's menu, to keep it on screen. +const FLOAT_MENU_H: f32 = 110.0; + +/// The pallet menu of an edge's + button, styled like the right-click menu: +/// one row per pallet with its icon, its name and a check mark when it is +/// open on this edge. `checked` holds each pallet with that state. +fn dock_edge_menu( + side: crate::app::config::DockSide, + checked: &[(crate::ui::dock::PanelId, bool)], +) -> Element<'static, Message> { + use crate::ui::dock::DockMsg; + let rows: Vec> = checked + .iter() + .map(|(id, checked)| { + let id = *id; + let check: Element<'static, Message> = if *checked { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + button( + row![ + crate::ui::icons::themed(id.icon(), 16.0), + text(t!(id.title())).size(12).width(Fill), + check, + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::Dock(DockMsg::EdgeMenuToggle(side, id))) + .style(menu_row_style) + .padding([5, 8]) + .width(Fill) + .into() + }) + .collect(); + menu_frame(column(rows).spacing(1).padding(4).into()) +} + +/// A menu row button: transparent until hovered. +fn menu_row_style(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.palette(); + button::Style { + background: matches!(status, button::Status::Hovered | button::Status::Pressed) + .then(|| Background::Color(palette.background.weak.color)), + text_color: palette.background.base.text, + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + } +} + +/// The raised box a dock menu sits in. +fn menu_frame(rows: Element<'static, Message>) -> Element<'static, Message> { + container(rows) + .width(Length::Fixed(DOCK_MENU_W)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.35), + offset: iced::Vector::new(0.0, 4.0), + blur_radius: 12.0, + }, + ..Default::default() + }) + .into() +} + +/// Draggable bar between two stacked pallets of group `gi`; double-click +/// gives every pallet of the group the same height again. It spans exactly +/// the pallets' width (panel plus its divider): a `Fill` width would stretch +/// the whole edge column across the workspace and squeeze the drawing view. +fn dock_splitter( + side: crate::app::config::DockSide, + gi: usize, + upper: usize, + lower: usize, + col_w: f32, +) -> Element<'static, Message> { + let line = container(Space::new()) + .width(Length::Fixed(col_w + DOCK_DIVIDER_W)) + .height(Length::Fixed(5.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + mouse_area(line) + .on_press(Message::Dock(crate::ui::dock::DockMsg::SplitGrab( + side, gi, upper, lower, + ))) + .on_double_click(Message::Dock(crate::ui::dock::DockMsg::SplitReset( + side, gi, + ))) + .interaction(iced::mouse::Interaction::ResizingVertically) + .into() +} + +/// The blue drop ghost: a `w`×`h` outline of where pallet `id` lands, with +/// its name as the header. +fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { + let header = container( + text(t!(id.title())) + .size(12) + .wrapping(iced::widget::text::Wrapping::None) + .ellipsis(iced::advanced::text::Ellipsis::End), + ) + .width(Fill) + .padding([5, 8]) + .clip(true) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.primary.base.color)), + text_color: Some(palette.primary.base.text), + ..Default::default() + } + }); + container(column![header, Space::new()]) + .width(Length::Fixed(w.max(1.0))) + .height(Length::Fixed(h.max(1.0))) + .clip(true) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.base.color.scale_alpha(0.85), + )), + border: Border { + color: palette.primary.base.color, + width: 2.0, + radius: 0.0.into(), + }, + ..Default::default() + } + }) + .into() +} + +/// Position `content` with its top-left corner at workspace point (`x`, `y`). +fn place_at(content: Element<'_, Message>, x: f32, y: f32) -> Element<'_, Message> { + container(content) + .width(Fill) + .height(Fill) + .align_x(iced::alignment::Horizontal::Left) + .align_y(iced::alignment::Vertical::Top) + .padding(iced::Padding { + top: y.max(0.0), + right: 0.0, + bottom: 0.0, + left: x.max(0.0), + }) + .into() +} diff --git a/src/app/mod.rs b/src/app/mod.rs index f9d2cc2f5..682787688 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod commands; pub(crate) mod dim_viewport; #[cfg(test)] mod dimension_preview_tests; +mod dock; mod document; mod drafting_settings; pub(crate) mod expr_eval; @@ -814,6 +815,8 @@ pub(super) struct OpenCADStudio { render_mode_preview: Option, /// Whether the Properties panel is shown on the left (PROPERTIES). show_properties: bool, + /// The Layer Manager pallet (LAYERS) is open. + pub(crate) show_layers: bool, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, /// Node graph overlay over the viewport. @@ -843,17 +846,33 @@ pub(super) struct OpenCADStudio { pub(crate) bg_picker: Option, /// General edge-stack dock layout for the side panels. pub(crate) dock: crate::ui::dock::DockState, - /// Which panel is currently floated at full height (hovered, or a pinned - /// panel on top). - pub(crate) dock_expanded: Option, - /// Panel currently being dragged between sides / reordered. - pub(crate) dock_dragging: Option, - /// Panel currently being width-resized. - pub(crate) dock_resizing: Option, - /// Last pointer position during a drag / resize. + /// Pallet revealed although it auto-hides (its edge's group, or its + /// floating frame): hovered, being dragged, or just opened. Cleared when + /// the pointer leaves. + pub(crate) dock_peek: Option, + /// Reveal or hide waiting out the hover delay, with the generation its + /// timer carries; any newer hover supersedes it. + pub(crate) dock_hover_pending: Option, + pub(crate) dock_hover_gen: u64, + /// What the pointer is dragging in the dock (pallet, group, width, + /// splitter, floating size, Layer Manager column). + pub(crate) dock_drag: Option, + /// Pallet whose strip icon (or floating frame) the pointer is over. + pub(crate) dock_icon_hover: Option, + /// Last pointer position during a dock drag. pub(crate) dock_drag_last: Option, - /// Live drag target (side + index), shown as a highlight while dragging. - pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>, + /// Docked panel whose title bar the pointer is over (shows pin / close). + pub(crate) dock_title_hover: Option, + /// Edge whose icon-strip pallet menu (+) is open. + pub(crate) dock_edge_menu: Option, + /// Floating pallet whose title-bar right-click menu is open. + pub(crate) dock_float_menu: Option, + /// Pointer position over a floating title bar (bar-local), and where + /// its open menu was asked for. + pub(crate) dock_float_pointer: iced::Point, + pub(crate) dock_float_menu_at: iced::Point, + /// Group whose edge band (group grip) the pointer is over. + pub(crate) dock_grip_hover: Option<(crate::app::config::DockSide, usize)>, /// Reference-table column currently being width-resized (column index), /// with the last pointer position. Mirrors the Layers Name-column drag. pub(crate) xref_col_drag: Option, @@ -942,9 +961,6 @@ pub(super) struct OpenCADStudio { modal_drag_last: Option, /// True while the modal title bar is held (a drag is in progress). modal_dragging: bool, - /// Layer Manager: dragging the Name-column divider (width follows the - /// shared ModalDragMove flow). - layer_col_dragging: bool, /// Layer Manager Name column width in px, adjusted by the divider drag. layer_name_col_w: f32, /// How far the user has dragged the modal's corner resize grip from the @@ -1951,7 +1967,6 @@ pub enum ModalKind { PluginManager, UpdateNotice, DonationPrompt, - Layers, LayerStateManager, LayerTranslator, DrawingUnits, @@ -4293,6 +4308,7 @@ impl OpenCADStudio { render_mode_menu_open: false, render_mode_preview: None, show_properties: true, + show_layers: false, show_block_palette: false, show_node_graph: false, property_target_override: None, @@ -4308,11 +4324,18 @@ impl OpenCADStudio { block_palette: Default::default(), xref_manager: Default::default(), dock: Default::default(), - dock_expanded: None, - dock_dragging: None, - dock_resizing: None, + dock_peek: None, + dock_hover_pending: None, + dock_hover_gen: 0, + dock_float_menu: None, + dock_float_pointer: iced::Point::ORIGIN, + dock_float_menu_at: iced::Point::ORIGIN, + dock_drag: None, dock_drag_last: None, - dock_drag_target: None, + dock_title_hover: None, + dock_edge_menu: None, + dock_grip_hover: None, + dock_icon_hover: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, @@ -4349,7 +4372,6 @@ impl OpenCADStudio { modal_offset: iced::Vector::ZERO, modal_drag_last: None, modal_dragging: false, - layer_col_dragging: false, layer_name_col_w: 130.0, modal_resize: iced::Vector::ZERO, modal_content_size: None, diff --git a/src/app/node_graph.rs b/src/app/node_graph.rs index 695588a13..d82ecf77f 100644 --- a/src/app/node_graph.rs +++ b/src/app/node_graph.rs @@ -188,11 +188,7 @@ impl OpenCADStudio { // The node library docks on the right on first use; after // that it stays wherever the user moved it. let id = crate::ui::dock::PanelId::NodeGraph; - 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); return self.graph_run(i); } } diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs index fa81e7606..554dcc328 100644 --- a/src/app/update/blocks_palette.rs +++ b/src/app/update/blocks_palette.rs @@ -134,7 +134,7 @@ impl OpenCADStudio { /// BLOCKSPALETTE: open (expanded) on `tab`, or on the tab it was left on. pub(crate) fn open_blocks_palette(&mut self, tab: Option) { self.show_block_palette = true; - self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette); + self.dock_reveal(crate::ui::dock::PanelId::BlockPalette); self.show_block_palette_tab(tab.unwrap_or(self.block_palette.tab)); } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 6baeaa2b0..436f7a5dc 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -542,195 +542,6 @@ impl OpenCADStudio { Ok(name) } - /// Dock chrome interaction (grab / pin / resize / hover / move) applied to - /// whichever panel the message names. - pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task { - use crate::app::config::DockSide; - use crate::ui::dock::{DockMsg, PanelId}; - match m { - DockMsg::DockGrab(id) => { - self.dock_dragging = Some(id); - self.dock_resizing = None; - self.dock_drag_last = None; - self.dock_drag_target = self.dock.location(id); - self.dock_expanded = Some(id); - iced::Task::none() - } - DockMsg::ResizeGrab(id) => { - self.dock_resizing = Some(id); - self.dock_dragging = None; - self.dock_drag_last = None; - self.dock_drag_target = None; - self.dock_expanded = Some(id); - iced::Task::none() - } - DockMsg::WidthReset(id) => { - self.dock.reset_width(id); - self.save_config(); - iced::Task::none() - } - DockMsg::AutoCollapseToggle(id) => { - let on = !self.dock.auto_collapse(id); - self.dock.set_auto_collapse(id, on); - self.dock_expanded = if on { None } else { Some(id) }; - self.save_config(); - iced::Task::none() - } - DockMsg::Close(id) => { - match id { - PanelId::BlockPalette => { - self.show_block_palette = false; - self.block_palette.placing = None; - } - PanelId::ExternalReferences => { - self.show_external_references = false; - } - PanelId::Browser => { - self.show_browser = false; - } - PanelId::NodeGraph => { - self.show_node_graph = false; - } - PanelId::PointCloudManager => { - self.pc_manager.show = false; - } - PanelId::Count => { - self.count_palette.show = false; - self.ribbon.set_count_palette(false); - } - PanelId::SheetSetManager => { - self.sheet_set.show = false; - self.ribbon.set_sheet_set(false); - } - PanelId::Properties => { - self.show_properties = false; - self.ribbon.set_properties(false); - } - } - if self.dock_expanded == Some(id) { - self.dock_expanded = None; - } - if self.dock_dragging == Some(id) { - self.dock_dragging = None; - } - if self.dock_resizing == Some(id) { - self.dock_resizing = None; - } - iced::Task::none() - } - DockMsg::Hover(id) => { - // Ignored while dragging/resizing: the pointer is over the - // drag preview, not a rail, so a hover must not collapse the - // panel being dragged or repoint the resize target. - if self.dock_dragging.is_none() && self.dock_resizing.is_none() { - self.dock_expanded = Some(id); - } - iced::Task::none() - } - DockMsg::HoverExit => { - if self.dock_dragging.is_none() && self.dock_resizing.is_none() { - if let Some(id) = self.dock_expanded { - if self.dock.auto_collapse(id) { - self.dock_expanded = None; - } - } - } - iced::Task::none() - } - DockMsg::DragMove(point) => { - // NOTE: xref column drags intentionally do NOT ride this - // path: its points live in workspace space while the header - // tracker reports header-local points, and mixing the two - // produced a one-time jump plus a stuck drag. Column moves - // arrive via Message::XrefColMove only. - if self.dock_dragging.is_some() { - let avail = self.tabs[self.active_tab] - .scene - .selection - .borrow() - .view - .vp_size - .1; - let side = if point.x < self.win_size.0 * 0.5 { - DockSide::Left - } else { - DockSide::Right - }; - let index = crate::ui::dock::drop_index( - point.y, - std::cmp::max(self.dock_visible_len(side), 1), - avail, - ); - self.dock_drag_target = Some((side, index)); - } else if let Some(id) = self.dock_resizing { - if let Some(last) = self.dock_drag_last { - let dx = point.x - last.x; - let delta = match self.dock.location(id) { - Some((DockSide::Left, _)) => dx, - _ => -dx, - }; - let cur = self.dock.settings(id).width + delta; - self.dock.set_width(id, cur); - } - } - if self.dock_dragging.is_some() || self.dock_resizing.is_some() { - self.dock_drag_last = Some(point); - } - iced::Task::none() - } - DockMsg::DragRelease => { - if let Some(id) = self.dock_dragging { - if let Some((side, index)) = self.dock_drag_target { - if self.dock.dock(id, side, index) { - self.save_config(); - } - } - } - self.dock_dragging = None; - self.dock_resizing = None; - self.dock_drag_last = None; - self.dock_drag_target = None; - self.xref_col_drag = None; - self.xref_col_last = None; - self.xref_split_drag = false; - iced::Task::none() - } - } - } - - /// Whether `id` is currently rendered (not closed, not on the start screen / - /// clean-screen viewport). Mirrors the visibility filter the edge column - /// renderer applies before building each side's stack. - pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool { - use crate::ui::dock::PanelId; - if self.tabs[self.active_tab].is_start || self.clean_screen { - return false; - } - match id { - PanelId::Properties => self.show_properties, - PanelId::BlockPalette => self.show_block_palette, - PanelId::ExternalReferences => self.show_external_references, - PanelId::Browser => self.show_browser, - PanelId::NodeGraph => self.show_node_graph, - PanelId::PointCloudManager => self.pc_manager.show, - PanelId::Count => self.count_palette.show, - PanelId::SheetSetManager => self.sheet_set.show, - } - } - - /// Number of panels currently rendered on `side`. Closed panels keep their - /// stack slot but take no vertical space, so drag preview geometry must - /// size slots against only the panels that are actually visible. - pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize { - let ids: &[crate::ui::dock::PanelId] = match side { - crate::app::config::DockSide::Left => &self.dock.left, - crate::app::config::DockSide::Right => &self.dock.right, - }; - ids.iter() - .filter(|id| self.dock_panel_visible(**id)) - .count() - } - /// Rebuild the Reference Manager's entry list from the active drawing. /// Uses the tab's session sets (unloaded + stat baseline) so CLI and /// palette agree; writes fresh load-time mtimes back into the tab's stat @@ -1605,134 +1416,6 @@ mod tests { ); } - #[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_collapse(id), "pin enables auto-collapse"); - let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!( - !app.dock.auto_collapse(id), - "second pin disables auto-collapse" - ); - let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); - assert!(!app.show_block_palette, "close dismisses the sidebar"); - } - - #[test] - fn blockpalette_dock_moves_to_other_side_and_persists() { - 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(); - let id = crate::ui::dock::PanelId::BlockPalette; - assert_eq!( - app.dock.location(id), - Some((crate::app::config::DockSide::Right, 0)) - ); - let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - app.win_size = (1600.0, 900.0).into(); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 100.0, - ))); - assert_eq!( - app.dock_drag_target, - Some((crate::app::config::DockSide::Left, 0)) - ); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease); - assert_eq!( - app.dock.location(id), - Some((crate::app::config::DockSide::Left, 0)) - ); - } - - #[test] - fn dock_visible_len_counts_only_rendered_panels() { - let mut app = fresh(); - app.dock = Default::default(); - app.dock.left = vec![ - crate::ui::dock::PanelId::BlockPalette, - crate::ui::dock::PanelId::Properties, - ]; - // Left: block palette hidden, properties shown -> 1 visible panel. - app.show_block_palette = false; - app.show_properties = true; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); - // Reveal the block palette -> both count. - app.show_block_palette = true; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 2); - // A hidden (closed) panel counts for nothing even when stacked. - app.show_block_palette = false; - assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); - } - - #[test] - fn dock_drag_target_ignores_hidden_panels_on_the_side() { - // Reproduce the persisted layout that exposed a half-height ghost: two - // panels live in the left stack but the block palette is hidden - // (show_block_palette=false), so only Properties renders. Drag geometry - // must count only panels that are actually visible. - let mut app = fresh(); - app.dock = Default::default(); - app.dock.left = vec![ - crate::ui::dock::PanelId::BlockPalette, - crate::ui::dock::PanelId::Properties, - ]; - app.show_block_palette = false; - app.show_properties = 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(); - let id = crate::ui::dock::PanelId::Properties; - let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - // Pointer near the bottom of the left edge: one visible panel means a - // single slot, so every y maps to index 0 (no top/bottom split). - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 850.0, - ))); - assert_eq!( - app.dock_drag_target, - Some((crate::app::config::DockSide::Left, 0)) - ); - } - - #[test] - fn dock_drag_target_counts_all_visible_panels() { - let mut app = fresh(); - app.dock = Default::default(); - app.dock.left = vec![ - crate::ui::dock::PanelId::BlockPalette, - crate::ui::dock::PanelId::Properties, - ]; - // Both panels shown: two real slots on the left edge. A pointer near - // the bottom maps to the append slot (index == 2). - app.show_block_palette = true; - app.show_properties = 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(); - let id = crate::ui::dock::PanelId::Properties; - let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( - 100.0, 850.0, - ))); - assert_eq!( - app.dock_drag_target, - Some((crate::app::config::DockSide::Left, 2)) - ); - } - - #[test] - fn blockpalette_width_reset() { - let mut app = fresh(); - let id = crate::ui::dock::PanelId::BlockPalette; - app.dock.set_width(id, 500.0); - let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id)); - assert_eq!(app.dock.settings(id).width, 260.0); - } - #[test] fn block_name_from_file_avoids_collisions() { let mut app = fresh(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1a13af163..645ef10ee 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2608,27 +2608,19 @@ impl OpenCADStudio { Message::ScriptLine(line) => self.feed_script_line(&line), Message::ToggleLayers => { - if self.active_modal == Some(super::ModalKind::Layers) { - self.ribbon.deactivate_tool_if("LAYERS"); - self.active_modal = None; - self.reset_modal_geometry(); - } else { - self.sync_ribbon_layers(); - self.active_modal = Some(super::ModalKind::Layers); + if self.show_layers { + return self.on_dock(crate::ui::dock::DockMsg::Close( + crate::ui::dock::PanelId::Layers, + )); } + self.open_layers_pallet(); Task::none() } Message::ToggleXrefManager => { self.show_external_references ^= true; if self.show_external_references { - use crate::app::config::DockSide; - use crate::ui::dock::PanelId; - if self.dock.location(PanelId::ExternalReferences).is_none() { - self.dock - .dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); - } - self.dock_expanded = Some(PanelId::ExternalReferences); + self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences); self.refresh_xref_manager(); } Task::none() @@ -6362,8 +6354,11 @@ impl OpenCADStudio { // Ctrl+A in the MText editor selects all of its text. self.mtext_select_all(); Task::none() - } else if self.active_modal == Some(super::ModalKind::Layers) { - // Select every row in the Layer Manager (#236). + } else if self.dock_panel_visible(crate::ui::dock::PanelId::Layers) + && self.tabs[i].layers.selected.is_some() + { + // Select every row in the Layer Manager (#236) while the + // user is working in it (a layer row is selected). let n = self.tabs[i].layers.layers.len(); self.tabs[i].layers.selected_multi = (0..n).collect(); self.tabs[i].layers.selected = (n > 0).then_some(0); @@ -9144,17 +9139,16 @@ impl OpenCADStudio { Task::none() } Message::LayerNameColGrab => { - // Start a Name-column divider drag; rides ModalDragMove. - self.layer_col_dragging = true; - self.modal_drag_last = None; + // Start a Name-column divider drag; the dock's pointer capture + // feeds it (see `on_dock`). + self.dock_drag = Some(crate::ui::dock::DockDrag::LayerColumn); + self.dock_drag_last = None; Task::none() } Message::ModalDragMove(p) => { if let Some(last) = self.modal_drag_last { let (dx, dy) = (p.x - last.x, p.y - last.y); - if self.layer_col_dragging { - self.layer_name_col_w = (self.layer_name_col_w + dx).clamp(60.0, 640.0); - } else if self.modal_resizing { + if self.modal_resizing { // The grip sits bottom-right, so dragging out grows the // box. The delta is added to each dialog's natural size, // so clamp it at zero — dragging in past the natural size @@ -9188,7 +9182,7 @@ impl OpenCADStudio { } } } - if self.modal_dragging || self.modal_resizing || self.layer_col_dragging { + if self.modal_dragging || self.modal_resizing { self.modal_drag_last = Some(p); } Task::none() @@ -9196,7 +9190,6 @@ impl OpenCADStudio { Message::ModalDragRelease => { self.modal_dragging = false; self.modal_resizing = false; - self.layer_col_dragging = false; self.modal_drag_last = None; Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 8a99d183b..933d30211 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -9,11 +9,11 @@ use crate::t; use crate::ui::wrap_bar::DensitySwap; use crate::ui::wrap_bar::WrapFlow; use iced::widget::{ - button, canvas, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, - stack, text, Row, Space, + button, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, stack, + text, Row, Space, }; use iced::window; -use iced::{keyboard, Background, Border, Color, Element, Fill, Length, Subscription, Task, Theme}; +use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; use iced_aw::ContextMenu; use std::sync::Arc; @@ -1889,29 +1889,10 @@ bg={bg_ms:.1}ms n={view_count}" viewport_stack = viewport_stack.push(tab.graph.view(sections)); } - // Docked side panels (Properties, block palette, future palettes) live - // in an ordered vertical stack on the left/right edge of the drawing - // view. Auto-collapsing (pinned) panels that aren't being hovered - // reduce to a rail sharing 1/N of the edge column's height; the - // hovered or unpinned panel expands to the full column height on top. - // Single source of truth with dock_visible_len (#57): one new panel - // updated in only one place used to break drag math vs rendering. - let visible_panel = |id: crate::ui::dock::PanelId| -> bool { self.dock_panel_visible(id) }; - let edge_stack = |side: crate::app::config::DockSide| -> Option> { - let ids: Vec = match side { - crate::app::config::DockSide::Left => self.dock.left.clone(), - crate::app::config::DockSide::Right => self.dock.right.clone(), - } - .into_iter() - .filter(|id| visible_panel(*id)) - .collect(); - if ids.is_empty() { - return None; - } - Some(self.build_edge_stack(side, &ids, tab)) - }; - let left_edge = edge_stack(crate::app::config::DockSide::Left); - let right_edge = edge_stack(crate::app::config::DockSide::Right); + // Docked pallets: each edge's icon strip and shown group (see + // `app::dock`). + let left_edge = self.build_edge_stack(crate::app::config::DockSide::Left, tab); + let right_edge = self.build_edge_stack(crate::app::config::DockSide::Right, tab); // Command-line sits as a bottom-centre overlay on top of the // viewport stack rather than as a separate row in the main @@ -1957,158 +1938,7 @@ bg={bg_ms:.1}ms n={view_count}" } let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into(); - let any_dragging = self.dock_dragging.is_some(); - let any_resizing = - self.dock_resizing.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag; - let workspace = if any_dragging { - let id = self.dock_dragging.expect("guarded by any_dragging"); - let side = self.dock_drag_target.map(|(s, _)| s).unwrap_or( - self.dock - .location(id) - .map(|(s, _)| s) - .unwrap_or(crate::app::config::DockSide::Right), - ); - // Dropping onto an edge joins that edge's stack, whose column is as - // wide as its widest currently-shown panel (the dragged panel - // included). Hidden (closed) panels don't render, so they can't - // widen the column being previewed. - let preview_width = { - let mut widths: Vec = match side { - crate::app::config::DockSide::Left => &self.dock.left, - crate::app::config::DockSide::Right => &self.dock.right, - } - .iter() - .copied() - .filter(|&pid| self.dock_panel_visible(pid)) - .map(|pid| self.dock.width(pid, self.win_size.0)) - .collect(); - widths.push(self.dock.width(id, self.win_size.0)); - widths.into_iter().fold(0.0, f32::max) - }; - // Faint tint over the whole edge column so the target edge reads - // even before the panel-sized ghost snaps to a slot. - let edge_tint = container(Space::new()) - .width(Length::Fixed(preview_width)) - .height(Fill) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color( - palette.primary.weak.color.scale_alpha(0.35), - )), - ..Default::default() - } - }); - // The ghost is the dragged panel at its real size: its own saved - // width and its 1/N share of the edge (N = number of panels shown - // on the edge after the drop), with a header naming which panel is - // being moved. Only visibly-docked panels share the column height: - // a hidden (closed) panel keeps its stack slot but no screen space, - // so it must not split the preview. - let was_here = self.dock.location(id).map(|(s, _)| s) == Some(side); - let visible = self.dock_visible_len(side); - let final_count = if was_here { - std::cmp::max(visible, 1) - } else { - visible + 1 - }; - let edge_h = tab.scene.selection.borrow().view.vp_size.1; - let slot_h = edge_h / final_count as f32; - let index = self.dock_drag_target.map(|(_, i)| i).unwrap_or(0); - let slot = index.min(final_count.saturating_sub(1)); - let ghost_top = slot as f32 * slot_h; - let ghost_header = container(text(id.title()).size(12)) - .width(Fill) - .padding(iced::Padding { - top: 5.0, - right: 8.0, - bottom: 5.0, - left: 8.0, - }) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.primary.base.color)), - text_color: Some(palette.primary.base.text), - ..Default::default() - } - }); - let ghost_panel = container(column![ghost_header, Space::new()]) - .width(Length::Fixed(self.dock.width(id, self.win_size.0))) - .height(Length::Fixed(slot_h)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.primary.base.color, - width: 2.0, - radius: 0.0.into(), - }, - ..Default::default() - } - }); - // Position the ghost at its drop slot, flush against the edge. - let ghost = container(ghost_panel) - .width(Fill) - .height(Fill) - .align_x(match side { - crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, - crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, - }) - .align_y(iced::alignment::Vertical::Top) - .padding(iced::Padding { - top: ghost_top, - right: 0.0, - bottom: 0.0, - left: 0.0, - }); - // Thin line across the column at the drop slot's top boundary. - let drop_line = container( - container(Space::new()) - .width(Fill) - .height(Length::Fixed(3.0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().primary.base.color)), - ..Default::default() - }), - ) - .width(Fill) - .height(Fill) - .align_y(iced::alignment::Vertical::Top) - .padding(iced::Padding { - top: ghost_top, - right: 0.0, - bottom: 0.0, - left: 0.0, - }); - let preview = iced::widget::stack![edge_tint, ghost, drop_line] - .width(Length::Fixed(preview_width)) - .height(Fill); - let preview = container(preview) - .width(Fill) - .height(Fill) - .align_x(match side { - crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left, - crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right, - }); - stack![workspace, preview].width(Fill).height(Fill).into() - } else { - workspace - }; - let workspace: Element<'_, Message> = if any_dragging || any_resizing { - mouse_area(workspace) - .on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p))) - .on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease)) - .interaction(if any_resizing { - iced::mouse::Interaction::ResizingHorizontally - } else { - iced::mouse::Interaction::Grabbing - }) - .into() - } else { - workspace - }; + let workspace = self.dock_overlay(workspace, tab); let command_line = self.command_line.view( allow_autocomplete, dyn_capturing, @@ -2900,198 +2730,6 @@ impl OpenCADStudio { pub(super) fn unfocus_widgets(&self) -> Task { iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus()) } - - /// Build one edge: a narrow tab strip at the very edge plus the expanded - /// panels sliding out beside it. Auto-collapsing (pinned) panels are always - /// visible as equal-height 1/N tabs in the strip (via `Fill` distribution); - /// hovering a tab raises its panel to full column height beside the strip, - /// so switching between stacked panels is a direct tab-to-tab hover with no - /// width-jumping collapse dance. Unpinned panels are always expanded. The - /// whole edge is one hover region: leaving it collapses any auto-collapsing - /// panel, but moving between the tabs and the expanded panel does not. - fn build_edge_stack<'a>( - &'a self, - side: crate::app::config::DockSide, - ids: &[crate::ui::dock::PanelId], - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - let pinned: Vec = ids - .iter() - .copied() - .filter(|id| self.dock.auto_collapse(*id)) - .collect(); - // Expanded = unpinned panels (always open) in stack order, then the - // hovered auto-collapsing one last so it floats on top. - let mut expanded: Vec = ids - .iter() - .copied() - .filter(|id| !self.dock.auto_collapse(*id)) - .collect(); - if let Some(id) = self.dock_expanded { - if ids.contains(&id) && self.dock.auto_collapse(id) { - expanded.push(id); - } - } - // Each expanded panel renders at its own saved width; the column is as - // wide as the widest one currently showing. - let col_w = expanded - .iter() - .map(|id| self.dock.width(*id, self.win_size.0)) - .fold(0.0, f32::max); - - let tab_strip = (!pinned.is_empty()).then(|| { - let tabs: Vec> = pinned - .iter() - .map(|id| { - self.rail_slice( - *id, - side, - self.dock_expanded == Some(*id), - self.dock_dragging == Some(*id), - ) - }) - .collect(); - column(tabs).width(DOCK_RAIL_W).height(Fill).into() - }); - let expanded_stack = (!expanded.is_empty()).then(|| { - // Multiple simultaneously-expanded panels (e.g. two unpinned panels - // on the same edge) stack one below the other, each taking 1/N of - // the column height via equal `Fill` shares — never overlapping. - let layers: Vec> = expanded - .iter() - .map(|id| self.expanded_panel(*id, side, col_w, tab)) - .collect(); - column(layers).height(Fill).into() - }); - - let mut children: Vec> = Vec::new(); - match side { - crate::app::config::DockSide::Left => { - if let Some(t) = tab_strip { - children.push(t); - } - if let Some(e) = expanded_stack { - children.push(e); - } - } - crate::app::config::DockSide::Right => { - if let Some(e) = expanded_stack { - children.push(e); - } - if let Some(t) = tab_strip { - children.push(t); - } - } - } - mouse_area(row(children).height(Fill)) - .on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit)) - .into() - } - - /// One narrow tab in the edge strip. Equal `Fill` heights across the tabs - /// give each 1/N of the strip; hovering or clicking it raises its panel. - fn rail_slice( - &self, - id: crate::ui::dock::PanelId, - side: crate::app::config::DockSide, - is_active: bool, - is_dragging: bool, - ) -> Element<'_, Message> { - let label = canvas(VBarLabel { - text: id.title().to_string(), - clockwise: side == crate::app::config::DockSide::Left, - }) - .width(Fill) - .height(Fill); - let bg = move |theme: &Theme| { - let palette = theme.palette(); - if is_active { - palette.primary.weak.color - } else if is_dragging { - palette.primary.weak.color.scale_alpha(0.55) - } else { - palette.background.base.color - } - }; - let tab = container(label) - .width(Length::Fixed(DOCK_RAIL_W)) - .height(Fill) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(bg(theme))), - border: Border { - color: if is_active { - theme.palette().primary.base.color - } else { - theme.palette().background.neutral.color - }, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }); - mouse_area(tab) - .interaction(iced::mouse::Interaction::Pointer) - .on_press(Message::Dock(crate::ui::dock::DockMsg::DockGrab(id))) - .on_enter(Message::Dock(crate::ui::dock::DockMsg::Hover(id))) - .into() - } - - /// A panel expanded to the full edge column: the panel body plus a - /// grabbable divider against the viewport. Hovering is handled by the - /// enclosing edge region (see `build_edge_stack`), so the body stays fully - /// interactive without fighting the region's hover tracking. - fn expanded_panel<'a>( - &'a self, - id: crate::ui::dock::PanelId, - side: crate::app::config::DockSide, - width: f32, - tab: &'a DocumentTab, - ) -> Element<'a, Message> { - let auto_collapse = self.dock.auto_collapse(id); - let panel: Element<'_, Message> = match id { - crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse), - crate::ui::dock::PanelId::BlockPalette => { - crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) - } - crate::ui::dock::PanelId::ExternalReferences => { - self.xref_manager - .view(width, auto_collapse, tab.xref_missing, &tab.scene.document) - } - crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( - &tab.scene.document, - tab.sketch_session - .as_ref() - .map(|session| session.name.as_str()), - width, - auto_collapse, - ), - crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, auto_collapse), - crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view( - &self.count_palette, - tab.count.as_ref(), - &tab.scene.document, - tab.id, - tab.scene.geometry_epoch, - width, - auto_collapse, - ), - crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( - &self.pc_manager, - &tab.scene.document, - &tab.scene.selected_handles_in_order(), - width, - auto_collapse, - ), - crate::ui::dock::PanelId::SheetSetManager => { - crate::ui::window::sheet_set::view(&self.sheet_set, width, auto_collapse) - } - }; - let divider = dock_divider(id); - match side { - crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), - crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(), - } - } } // ── Document tab bar ─────────────────────────────────────────────────────── @@ -3420,68 +3058,6 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> { .into() } -/// Canvas that draws a label rotated 90° (for a collapsed panel's bar). -struct VBarLabel { - text: String, - clockwise: bool, -} - -impl canvas::Program for VBarLabel { - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced::Renderer, - theme: &Theme, - bounds: iced::Rectangle, - _cursor: iced::advanced::mouse::Cursor, - ) -> Vec { - let mut frame = canvas::Frame::new(renderer, bounds.size()); - frame.with_save(|frame| { - frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); - frame.rotate(iced::Radians(if self.clockwise { - std::f32::consts::FRAC_PI_2 - } else { - -std::f32::consts::FRAC_PI_2 - })); - frame.fill_text(canvas::Text { - content: self.text.clone(), - position: iced::Point::ORIGIN, - color: theme.palette().background.base.text.scale_alpha(0.72), - size: iced::Pixels(13.0), - align_x: iced::advanced::text::Alignment::Center, - align_y: iced::alignment::Vertical::Center, - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); - }); - vec![frame.into_geometry()] - } -} - -/// Width of a collapsed (auto-collapsing) panel's tab in the edge strip. -const DOCK_RAIL_W: f32 = 28.0; - -/// Grabbable separator for a docked panel managed by the general dock. Same -/// visual as the previous per-panel divider but emits generic dock messages. -fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { - let line = container(Space::new()) - .width(Length::Fixed(5.0)) - .height(Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.neutral.color)), - ..Default::default() - }); - let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id)); - let reset = Message::Dock(crate::ui::dock::DockMsg::WidthReset(id)); - mouse_area(line) - .on_press(grab) - .on_double_click(reset) - .interaction(iced::mouse::Interaction::ResizingHorizontally) - .into() -} - const START_ACTION_RADIUS: f32 = 6.0; fn start_action_shape(mut style: button::Style) -> button::Style { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 5cf0dbbd4..c6d45b677 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -19,7 +19,6 @@ impl OpenCADStudio { Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"), Some(K::UpdateNotice) => crate::tr!("modal", "update-available"), Some(K::DonationPrompt) => crate::tr!("donation", "title"), - Some(K::Layers) => crate::tr!("modal", "layer-manager"), Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"), Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), @@ -465,12 +464,6 @@ impl OpenCADStudio { crate::ui::window::update_notice::view_window(latest, body, flow) }) } - super::super::ModalKind::Layers => { - let tab = &self.tabs[self.active_tab]; - sized_flow(ex, 900, 360, |flow| { - tab.layers.view_window(self.layer_name_col_w, flow) - }) - } super::super::ModalKind::LayerTranslator => { use crate::modules::draw::layers::laytrans; let i = self.active_tab; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 80d3f10f8..efbe56a04 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5765,6 +5765,13 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Invalid count area boundary object. Select a closed polyline consisting of line segments and does not intersect itself." => Some(("count-feature", "invalid-boundary")), "Block \"{part}\" cannot be found." => Some(("count-feature", "block-not-found")), "Invalid point." => Some(("count-feature", "invalid-point")), + "Auto-hide" => Some(("dock", "auto-hide")), + "Allow docking" => Some(("dock", "allow-docking")), + "Dock left" => Some(("dock", "dock-left")), + "Dock right" => Some(("dock", "dock-right")), + "New group" => Some(("dock", "new-group")), + "Browser" => Some(("dock", "browser")), + "Node Graph" => Some(("dock", "node-graph")), _ => None, } } diff --git a/src/modules/draw/layers/panel.rs b/src/modules/draw/layers/panel.rs index 9ea4c9cf6..14212fa1c 100644 --- a/src/modules/draw/layers/panel.rs +++ b/src/modules/draw/layers/panel.rs @@ -1,4 +1,4 @@ -// Layers panel toggle — ribbon definition. +// Layer Manager pallet — ribbon definition. use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -7,6 +7,8 @@ pub fn tool() -> ToolDef { id: "LAYERS", label: "Layers", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/layers/panel.svg")), - event: ModuleEvent::ToggleLayers, + // A command like the Blocks palette's: it opens the pallet, and the + // ribbon drops the button's highlight once it has run. + event: ModuleEvent::Command("LAYERS".to_string()), } } diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 37dce336d..771c4b908 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -1,11 +1,12 @@ -//! General edge-stack docking for side panels. +//! Docking for side panels (pallets). //! -//! Any number of dockable panels (Properties, the block palette, future -//! palettes) live in an ordered vertical stack on the left or right edge of -//! the drawing view. This module owns the persisted layout (which panels are -//! docked, on which side, in what vertical order, at what width, and whether -//! each auto-collapses) plus the pure geometry used to render and hover an -//! edge's stacked panels. +//! Pallets (Properties, the block palette, the Layer Manager, …) dock on the +//! left or right edge of the drawing view, or float over it. Each edge holds +//! an ordered list of groups and shows one group at a time; a group's pallets +//! stack top to bottom, each with an adjustable share of the height, and the +//! group has its own width. An icon strip along the edge lists every group. +//! This module owns the persisted layout and the pure geometry used to draw +//! and hit-test it; the app owns the transient drag / hover state. use crate::app::config::DockSide; use serde::{Deserialize, Serialize}; @@ -17,24 +18,87 @@ use std::collections::BTreeMap; pub enum DockMsg { /// Begin dragging `panel` to another side / position. DockGrab(PanelId), - /// Begin resizing `panel`'s width. + /// Begin resizing the width of docked `panel`'s group. ResizeGrab(PanelId), - /// Reset `panel`'s width to its default. + /// Reset the width of docked `panel`'s group to its default. WidthReset(PanelId), /// Toggle `panel`'s auto-collapse (pin) behavior. AutoCollapseToggle(PanelId), /// Close / hide `panel`. Close(PanelId), - /// The pointer is over `panel`, raising it to full height. + /// The pointer entered a pallet's strip icon or floating frame: + /// highlights the icon and, where the pallet auto-hides, reveals it. Hover(PanelId), + /// The pointer left that icon or frame. Carries the pallet so a late + /// "left" never clears the hover of the element just entered. + HoverEnd(PanelId), /// Pointer moved while a panel is dragging or resizing. DragMove(iced::Point), /// Pointer released after a drag / resize. DragRelease, + /// A press on `panel`'s strip icon: show its group, and start a possible + /// drag of the pallet. + IconPress(PanelId), + /// Begin dragging the splitter between pallets `upper` and `lower` of a + /// group (side, group, upper, lower). + SplitGrab(DockSide, usize, usize, usize), + /// Give every pallet of a group the same height again (side, group). + SplitReset(DockSide, usize), + /// Begin resizing floating `panel` from its corner grip; `true` for the + /// bottom-left grip (a panel whose title bar is on the right). + FloatResizeGrab(PanelId, bool), + /// The pointer entered a docked title bar (shows its pin and close). + TitleHover(PanelId), + /// The pointer left that title bar. + TitleHoverEnd(PanelId), + /// Open (`Some`) the pallet menu of an edge's icon strip (its + button), + /// or close it (`None`). + EdgeMenu(Option), + /// Pallet menu pick: hide `panel` when it shows, else open it docked on + /// the edge (as a new group unless it already has a group there). + EdgeMenuToggle(DockSide, PanelId), + /// Begin dragging a whole group by its edge band in the icon strip. + GroupGrab(DockSide, usize), + /// The pointer entered a group's edge band (side, group). + GripHover(DockSide, usize), + /// The pointer left that edge band. + GripHoverEnd(DockSide, usize), + /// Double-click on a docked title bar: float the panel. + FloatOut(PanelId), + /// Double-click on a floating title bar (or its menu's Dock left / + /// right): dock the panel on that side. + DockTo(PanelId, DockSide), + /// Open (`Some`) the right-click menu of a floating title bar, or close + /// it (`None`). + FloatMenu(Option), + /// Floating menu's Allow docking check: flip it for the panel. + FloatDockingToggle(PanelId), + /// The pointer moved over a floating title bar, at this point in it + /// (where its right-click menu opens). + FloatBarPointer(iced::Point), + /// Bring floating `panel` to the front. + FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. HoverExit, + /// The pointer entered the edge column again: keep its group revealed. + HoverStay, + /// The hover delay of the reveal / hide armed as generation `n` ran out. + HoverSettled(u64), } +/// An auto-hide change waiting out [`DOCK_HOVER_DELAY`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoverPending { + /// Reveal this pallet (its group, or its floating frame). + Reveal(PanelId), + /// Hide whatever auto-hiding pallet is revealed. + Hide, +} + +/// How long the pointer must rest before an auto-hiding group reveals or +/// hides, so passing over the strip doesn't flash groups open. +pub const DOCK_HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(150); + /// The dockable panels the application knows about. New palettes add a variant. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -52,9 +116,24 @@ pub enum PanelId { Count, /// The open sheet sets: subsets and sheets. SheetSetManager, + /// The Layer Manager (LAYERS). + Layers, } impl PanelId { + /// Every panel, in the order the pallet menu lists them. + pub const ALL: [PanelId; 9] = [ + PanelId::Properties, + PanelId::BlockPalette, + PanelId::ExternalReferences, + PanelId::Browser, + PanelId::NodeGraph, + PanelId::PointCloudManager, + PanelId::Count, + PanelId::SheetSetManager, + PanelId::Layers, + ]; + /// Localized-friendly display name used by the collapsed/edge chrome. pub fn title(self) -> &'static str { match self { @@ -66,6 +145,22 @@ impl PanelId { PanelId::PointCloudManager => "Point Cloud Manager", PanelId::Count => "Count", PanelId::SheetSetManager => "Sheet Set Manager", + PanelId::Layers => "Layer Manager", + } + } + + /// The panel's icon: the one on the ribbon tool that opens it. + pub fn icon(self) -> &'static [u8] { + match self { + PanelId::Properties => include_bytes!("../../assets/icons/properties.svg"), + PanelId::BlockPalette => include_bytes!("../../assets/icons/blocks/insert.svg"), + PanelId::ExternalReferences => crate::ui::icons::FOLDER_OPEN, + PanelId::Browser => include_bytes!("../../assets/icons/content_browser.svg"), + PanelId::NodeGraph => crate::ui::icons::NODE_GRAPH, + PanelId::PointCloudManager => include_bytes!("../../assets/icons/pc_attach.svg"), + PanelId::Count => include_bytes!("../../assets/icons/data_extract.svg"), + PanelId::SheetSetManager => include_bytes!("../../assets/icons/sheetset.svg"), + PanelId::Layers => include_bytes!("../../assets/icons/layers/panel.svg"), } } @@ -80,6 +175,7 @@ impl PanelId { PanelId::PointCloudManager => 280.0, PanelId::Count => 280.0, PanelId::SheetSetManager => 280.0, + PanelId::Layers => 560.0, } } @@ -87,7 +183,7 @@ impl PanelId { /// column-rich, so it allows double the shared maximum. fn max_width(self) -> f32 { match self { - PanelId::ExternalReferences => DOCK_MAX_W * 2.0, + PanelId::ExternalReferences | PanelId::Layers => DOCK_MAX_W * 2.0, _ => DOCK_MAX_W, } } @@ -119,76 +215,334 @@ impl DockPanel { } } -/// The whole dock layout: two ordered per-side stacks plus per-panel settings. -/// Only the persisted layout lives here; transient drag/hover state is app -/// state (see `update::mod`) so it is skipped by serialization. +/// One group of an edge: pallets shown together, stacked top to bottom, each +/// with an adjustable share of the edge height. An edge shows one group at a +/// time; its icon strip lists every group so the user can switch. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct DockGroup { + /// Pallets top → bottom. + pub panels: Vec, + /// Relative height of each pallet, parallel to `panels`. + pub weights: Vec, + /// The group's docked width; 0 until first sized, meaning "the widest + /// of its pallets' own widths" (see [`DockState::group_width`]). + #[serde(default)] + pub width: f32, +} + +impl DockGroup { + pub fn single(id: PanelId) -> Self { + Self::stack(vec![id]) + } + + pub fn stack(panels: Vec) -> Self { + let weights = vec![1.0; panels.len()]; + Self { + panels, + weights, + width: 0.0, + } + } + + /// Keep one sane weight per pallet (configs edited by hand, or written + /// by an older build). + fn heal(&mut self) { + self.weights.resize(self.panels.len(), 1.0); + for w in &mut self.weights { + *w = if w.is_finite() && *w > 0.0 { + w.clamp(MIN_WEIGHT, MAX_WEIGHT) + } else { + 1.0 + }; + } + } + + fn insert(&mut self, index: usize, id: PanelId, weight: f32) { + let index = index.min(self.panels.len()); + self.panels.insert(index, id); + self.weights.insert(index, weight); + } + + fn remove(&mut self, id: PanelId) -> Option<(usize, f32)> { + let i = self.panels.iter().position(|p| *p == id)?; + self.panels.remove(i); + Some((i, self.weights.remove(i))) + } + + fn mean_weight(&self) -> f32 { + if self.weights.is_empty() { + 1.0 + } else { + self.weights.iter().sum::() / self.weights.len() as f32 + } + } +} + +/// On-disk forms of one entry of an edge list. Configs from before groups +/// stored each stacked pallet as a bare id. +#[derive(Deserialize)] +#[serde(untagged)] +enum GroupRepr { + Single(PanelId), + Stack { + panels: Vec, + #[serde(default)] + weights: Vec, + #[serde(default)] + width: f32, + }, +} + +/// Read an edge list. Bare ids were pallets stacked on the edge, all shown +/// at once, so they become one group to keep the old layout. +fn edge_from_reprs<'de, D>(de: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let reprs = Vec::::deserialize(de)?; + let mut groups = Vec::new(); + let mut legacy: Vec = Vec::new(); + for repr in reprs { + match repr { + GroupRepr::Single(id) => legacy.push(id), + GroupRepr::Stack { + panels, + weights, + width, + } => groups.push(DockGroup { + panels, + weights, + width, + }), + } + } + if !legacy.is_empty() { + groups.insert(0, DockGroup::stack(legacy)); + } + for g in &mut groups { + g.heal(); + } + groups.retain(|g| !g.panels.is_empty()); + Ok(groups) +} + +/// A panel floating over the workspace, in workspace-local pixels. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "FloatRepr")] +pub struct FloatPanel { + pub id: PanelId, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + /// Dragging the panel may dock it on an edge; off, it only ever floats. + pub docking: bool, +} + +/// On-disk form of a floating panel. A short-lived build floated groups, +/// stored as `panels`: its first pallet keeps floating, so such a config +/// still loads (a config that fails to load falls back to all defaults). +#[derive(Deserialize)] +struct FloatRepr { + id: Option, + #[serde(default)] + panels: Vec, + x: f32, + y: f32, + w: f32, + h: f32, + #[serde(default = "docking_default")] + docking: bool, +} + +fn docking_default() -> bool { + true +} + +impl TryFrom for FloatPanel { + type Error = &'static str; + + fn try_from(r: FloatRepr) -> Result { + let id = + r.id.or(r.panels.first().copied()) + .ok_or("floating panel without id")?; + Ok(Self { + docking: r.docking, + ..Self::new(id, r.x, r.y, r.w, r.h) + }) + } +} + +impl FloatPanel { + /// Panel `id` floating at `x`, `y`, sized `w` × `h`, free to dock. + pub fn new(id: PanelId, x: f32, y: f32, w: f32, h: f32) -> Self { + Self { + id, + x, + y, + w, + h, + docking: true, + } + } + + /// Keep a placement read from disk usable: finite, on the workspace's + /// positive side, and at least the minimum size. + fn heal(&mut self) { + let finite_or = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + self.x = finite_or(self.x, 0.0).max(0.0); + self.y = finite_or(self.y, 0.0).max(0.0); + self.w = finite_or(self.w, self.id.default_width()).clamp(DOCK_MIN_W, self.id.max_width()); + self.h = finite_or(self.h, FLOAT_MIN_H).max(FLOAT_MIN_H); + } +} + +/// Smallest floating panel height. +pub const FLOAT_MIN_H: f32 = 160.0; +/// Smallest on-screen height of a stacked pallet while dragging a splitter. +pub const GROUP_MIN_H: f32 = 80.0; +const MIN_WEIGHT: f32 = 0.05; +const MAX_WEIGHT: f32 = 20.0; + +/// The whole dock layout: per edge its groups and which one shows, the +/// floating panels, plus per-panel settings. Only the persisted layout lives +/// here; transient drag/hover state is app state (see `update::mod`) so it is +/// skipped by serialization. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DockState { - /// Panels anchored to the left edge, top → bottom. - pub left: Vec, - /// Panels anchored to the right edge, top → bottom. - pub right: Vec, - /// Per-panel width / auto-collapse settings, keyed by `PanelId`. + /// Groups on the left edge, in icon-strip order. + #[serde(deserialize_with = "edge_from_reprs")] + pub left: Vec, + /// Groups on the right edge, in icon-strip order. + #[serde(deserialize_with = "edge_from_reprs")] + pub right: Vec, + /// The group each edge shows (left, right). + pub shown: (usize, usize), + /// Auto-hide per edge (left, right): the edge shows only its icon strip + /// until an icon is hovered. + pub auto_hide: (bool, bool), + /// Floating panels, back → front. + pub floating: Vec, + /// Per-panel settings, keyed by `PanelId`: the width a pallet floats at + /// (and starts a new group with) and its floating auto-hide. pub panels: BTreeMap, } impl Default for DockState { fn default() -> Self { Self { - left: vec![PanelId::Properties], - right: vec![PanelId::BlockPalette], + left: vec![DockGroup::single(PanelId::Properties)], + right: vec![DockGroup::single(PanelId::BlockPalette)], + shown: (0, 0), + auto_hide: (false, false), + floating: Vec::new(), panels: BTreeMap::new(), } } } impl DockState { - /// Edge-collection of the panels ordered for rendering on `side`. - fn stack(&self, side: DockSide) -> &[PanelId] { + /// The groups on `side`, in icon-strip order. + pub fn groups(&self, side: DockSide) -> &[DockGroup] { match side { DockSide::Left => &self.left, DockSide::Right => &self.right, } } - fn stack_mut(&mut self, side: DockSide) -> &mut Vec { + fn groups_mut(&mut self, side: DockSide) -> &mut Vec { match side { DockSide::Left => &mut self.left, DockSide::Right => &mut self.right, } } + /// The group `side` shows (it may hold no open pallet; see the app's + /// `dock_shown_group` for the one actually drawn). + pub fn shown(&self, side: DockSide) -> usize { + match side { + DockSide::Left => self.shown.0, + DockSide::Right => self.shown.1, + } + } + + /// Make group `gi` the one `side` shows. Returns whether it changed. + pub fn show_group(&mut self, side: DockSide, gi: usize) -> bool { + let slot = match side { + DockSide::Left => &mut self.shown.0, + DockSide::Right => &mut self.shown.1, + }; + let changed = *slot != gi; + *slot = gi; + changed + } + + /// Show the group holding `id`. Returns whether the shown group changed. + pub fn show_group_of(&mut self, id: PanelId) -> bool { + match self.location(id) { + Some((side, gi)) => self.show_group(side, gi), + None => false, + } + } + /// Guarantee every known `PanelId` has a settings entry, so rendering and /// resize never hit a missing configuration. Also a cheap heal for configs - /// written by an older version. + /// written by an older version (or edited by hand): a panel placed twice + /// keeps only its first placement. pub fn ensure_settings(&mut self) { - for id in [ - PanelId::Properties, - PanelId::BlockPalette, - PanelId::ExternalReferences, - PanelId::Browser, - PanelId::NodeGraph, - PanelId::PointCloudManager, - PanelId::Count, - PanelId::SheetSetManager, - ] { + for id in PanelId::ALL { self.panels .entry(id) .or_insert_with(|| DockPanel::for_id(id)); } + let mut seen = std::collections::BTreeSet::new(); + for side in [DockSide::Left, DockSide::Right] { + let groups = self.groups_mut(side); + for g in groups.iter_mut() { + g.heal(); + (g.panels, g.weights) = g + .panels + .iter() + .zip(&g.weights) + .filter(|(id, _)| seen.insert(**id)) + .map(|(id, w)| (*id, *w)) + .unzip(); + } + groups.retain(|g| !g.panels.is_empty()); + let len = groups.len(); + let shown = self.shown(side).min(len.saturating_sub(1)); + self.show_group(side, shown); + } + self.floating.retain(|f| seen.insert(f.id)); + self.floating.iter_mut().for_each(FloatPanel::heal); } - /// Where (if anywhere) a panel is currently docked. + /// Where (if anywhere) a panel is docked: its side and group index. pub fn location(&self, id: PanelId) -> Option<(DockSide, usize)> { for side in [DockSide::Left, DockSide::Right] { - if let Some(i) = self.stack(side).iter().position(|p| *p == id) { + if let Some(i) = self + .groups(side) + .iter() + .position(|g| g.panels.contains(&id)) + { return Some((side, i)); } } None } + /// The floating placement of `id`, if it floats. + pub fn float_rect(&self, id: PanelId) -> Option { + self.floating.iter().find(|f| f.id == id).copied() + } + + /// Whether `id` has a place in the layout (docked or floating). A panel + /// without one is docked on the right when it is opened. + pub fn is_placed(&self, id: PanelId) -> bool { + self.location(id).is_some() || self.float_rect(id).is_some() + } + pub fn settings(&self, id: PanelId) -> DockPanel { self.panels .get(&id) @@ -198,31 +552,42 @@ impl DockState { /// Docked width for `id`, clamped to sane bounds. pub fn width(&self, id: PanelId, win_w: f32) -> f32 { - self.settings(id) - .width - .clamp(DOCK_MIN_W, id.max_width().min(win_w * 0.45).max(DOCK_MIN_W)) + on_screen_width(self.settings(id).width, id.max_width(), win_w) } + /// The floating auto-hide flag of `id` (a docked pallet follows its + /// edge instead; see [`Self::auto_hides`]). pub fn auto_collapse(&self, id: PanelId) -> bool { self.settings(id).auto_collapse } - /// Set the persisted width, clamped. - pub fn set_width(&mut self, id: PanelId, width: f32) { - let entry = self - .panels - .entry(id) - .or_insert_with(|| DockPanel::for_id(id)); - entry.width = width.clamp(DOCK_MIN_W, id.max_width()); + /// Whether `side` auto-hides. + pub fn edge_auto_hide(&self, side: DockSide) -> bool { + match side { + DockSide::Left => self.auto_hide.0, + DockSide::Right => self.auto_hide.1, + } } - /// Reset width to the panel's default. - pub fn reset_width(&mut self, id: PanelId) { - let entry = self - .panels - .entry(id) - .or_insert_with(|| DockPanel::for_id(id)); - entry.width = id.default_width(); + /// Whether `id` auto-hides where it is: by its edge's setting when + /// docked, by its own when floating. + pub fn auto_hides(&self, id: PanelId) -> bool { + match self.location(id) { + Some((side, _)) => self.edge_auto_hide(side), + None => self.auto_collapse(id), + } + } + + /// Flip auto-hide where `id` is: its whole edge when docked, the pallet + /// itself when floating. Returns the new state. + pub fn toggle_auto_hide(&mut self, id: PanelId) -> bool { + let on = !self.auto_hides(id); + match self.location(id) { + Some((DockSide::Left, _)) => self.auto_hide.0 = on, + Some((DockSide::Right, _)) => self.auto_hide.1 = on, + None => self.set_auto_collapse(id, on), + } + on } pub fn set_auto_collapse(&mut self, id: PanelId, on: bool) { @@ -233,43 +598,438 @@ impl DockState { entry.auto_collapse = on; } - /// Dock `id` to `side` at `index` (clamped), removing it from any other - /// stack first. Returns whether the layout actually changed. + /// Group `group`'s docked width: its own once sized, else the widest of + /// its pallets' own widths. A group that no longer exists reads as the + /// minimum width. + pub fn group_width(&self, side: DockSide, group: usize) -> f32 { + let Some(g) = self.groups(side).get(group) else { + return DOCK_MIN_W; + }; + if g.width > 0.0 { + return g.width; + } + g.panels + .iter() + .map(|id| self.settings(*id).width) + .fold(DOCK_MIN_W, f32::max) + } + + /// The widest group `group` may be: the most any of its pallets allows. + fn group_max_width(&self, side: DockSide, group: usize) -> f32 { + self.groups(side) + .get(group) + .into_iter() + .flat_map(|g| g.panels.iter()) + .map(|id| id.max_width()) + .fold(DOCK_MIN_W, f32::max) + } + + /// Group `group`'s on-screen width in a `win_w` wide window, clamped like + /// [`Self::width`]. + pub fn group_width_px(&self, side: DockSide, group: usize, win_w: f32) -> f32 { + on_screen_width( + self.group_width(side, group), + self.group_max_width(side, group), + win_w, + ) + } + + /// Size group `group` on `side`, clamped. + pub fn set_group_width(&mut self, side: DockSide, group: usize, width: f32) { + let max = self.group_max_width(side, group); + if let Some(g) = self.groups_mut(side).get_mut(group) { + g.width = width.clamp(DOCK_MIN_W, max); + } + } + + /// Give group `group` the widest default width of its pallets. + pub fn reset_group_width(&mut self, side: DockSide, group: usize) { + let width = self + .groups(side) + .get(group) + .into_iter() + .flat_map(|g| g.panels.iter()) + .map(|id| id.default_width()) + .fold(DOCK_MIN_W, f32::max); + self.set_group_width(side, group, width); + } + + /// Take `id` out of the layout. Returns the group it left, its position + /// and weight there, and whether the group disappeared (it held nothing + /// else). + fn detach(&mut self, id: PanelId) -> Option<(DockSide, usize, usize, f32, bool)> { + self.floating.retain(|f| f.id != id); + let (side, gi) = self.location(id)?; + let (pos, weight) = self.groups_mut(side)[gi].remove(id).expect("located"); + if !self.groups(side)[gi].panels.is_empty() { + return Some((side, gi, pos, weight, false)); + } + self.groups_mut(side).remove(gi); + self.after_group_removed(side, gi); + Some((side, gi, pos, weight, true)) + } + + /// Group `gi` was taken off `side`: keep showing the same group, or its + /// neighbour when the shown group itself went away. + fn after_group_removed(&mut self, side: DockSide, gi: usize) { + let shown = self.shown(side); + if shown > gi || (shown == gi && shown > 0 && shown >= self.groups(side).len()) { + self.show_group(side, shown - 1); + } + } + + /// Dock `id` as a new group of its own on `side` at insertion `index` + /// (0 = first, `len` = last), counted before the move, and show it. + /// Returns whether the layout changed. pub fn dock(&mut self, id: PanelId, side: DockSide, index: usize) -> bool { - if let Some((old_side, old_i)) = self.location(id) { - if old_side == side && old_i == index { + let before = self.clone(); + let mut index = index; + if let Some((old_side, old_gi, _, _, removed)) = self.detach(id) { + if old_side == side && removed && old_gi < index { + index -= 1; + } + } + let groups = self.groups_mut(side); + let index = index.min(groups.len()); + groups.insert(index, DockGroup::single(id)); + self.show_group(side, index); + *self != before + } + + /// Stack `id` into group `group` on `side` at position `index` (0 = top, + /// `len` = bottom), both counted before the move, and show the group. A + /// pallet already in the group moves within it; the group keeps its + /// width. Returns whether the layout changed. + pub fn join_group(&mut self, id: PanelId, side: DockSide, group: usize, index: usize) -> bool { + if group >= self.groups(side).len() { + return false; + } + // A pallet alone in its group has nowhere to move within it (and + // detaching it would remove the group being joined). + if self.groups(side)[group].panels == [id] { + return self.show_group(side, group); + } + let before = self.clone(); + let mut group = group; + let mut index = index; + let mut weight = self.groups(side)[group].mean_weight(); + if let Some((old_side, old_gi, pos, w, removed)) = self.detach(id) { + if old_side == side && old_gi == group { + // Moving within the group keeps the pallet's height. + weight = w; + if pos < index { + index -= 1; + } + } else if old_side == side && removed && old_gi < group { + group -= 1; + } + } + self.groups_mut(side)[group].insert(index, id, weight); + self.show_group(side, group); + *self != before + } + + /// Move group `group` on `side` to insertion `index` on `to` (counted + /// before the move) and show it there. Returns whether the layout changed. + pub fn move_group(&mut self, side: DockSide, group: usize, to: DockSide, index: usize) -> bool { + if group >= self.groups(side).len() { + return false; + } + let mut index = index; + if side == to { + if index == group || index == group + 1 { return false; } - self.stack_mut(old_side).remove(old_i); + if group < index { + index -= 1; + } } - let stack = self.stack_mut(side); - let index = index.min(stack.len()); - stack.insert(index, id); + let g = self.groups_mut(side).remove(group); + // Moving within an edge shows the moved group below anyway. + if side != to { + self.after_group_removed(side, group); + } + let dest = self.groups_mut(to); + let index = index.min(dest.len()); + dest.insert(index, g); + self.show_group(to, index); true } + + /// Float `id` at `rect`, on top of the other floating panels. + pub fn float(&mut self, rect: FloatPanel) -> bool { + let before = self.clone(); + self.detach(rect.id); + self.floating.push(rect); + *self != before + } + + /// Flip whether dragging floating panel `id` may dock it. + pub fn toggle_float_docking(&mut self, id: PanelId) -> bool { + match self.floating.iter_mut().find(|f| f.id == id) { + Some(f) => { + f.docking = !f.docking; + true + } + None => false, + } + } + + /// Bring a floating panel to the front. + pub fn raise_float(&mut self, id: PanelId) { + if let Some(i) = self.floating.iter().position(|f| f.id == id) { + let f = self.floating.remove(i); + self.floating.push(f); + } + } + + /// Resize a floating panel, keeping it at least a usable size. With + /// `keep_right` the right edge stays put (resizing from the left corner). + pub fn resize_float(&mut self, id: PanelId, w: f32, h: f32, keep_right: bool) { + if let Some(f) = self.floating.iter_mut().find(|f| f.id == id) { + let new_w = w.clamp(DOCK_MIN_W, id.max_width()); + if keep_right { + f.x += f.w - new_w; + } + f.w = new_w; + f.h = h.max(FLOAT_MIN_H); + } + } + + /// Move the boundary between pallets `upper` and `lower` of group + /// `group` on `side` by `delta` weight units, keeping their combined + /// share and each above `min_weight`. + pub fn shift_split( + &mut self, + side: DockSide, + group: usize, + upper: usize, + lower: usize, + delta: f32, + min_weight: f32, + ) { + let Some(g) = self.groups_mut(side).get_mut(group) else { + return; + }; + let w = &mut g.weights; + if upper >= w.len() || lower >= w.len() || upper == lower { + return; + } + let total = w[upper] + w[lower]; + let min = min_weight.clamp(MIN_WEIGHT, total * 0.5); + let up = (w[upper] + delta).clamp(min, total - min); + w[upper] = up; + w[lower] = total - up; + } + + /// Give every pallet of group `group` on `side` the same height again. + pub fn reset_splits(&mut self, side: DockSide, group: usize) { + if let Some(g) = self.groups_mut(side).get_mut(group) { + g.weights.iter_mut().for_each(|w| *w = 1.0); + } + } } -/// Insertion index (0..=total) for dropping a panel whose pointer is at -/// screen-local `y` within an edge column of `avail` height holding `total` -/// slots. Used to compute the live drag target index while reordering. -pub fn drop_index(y: f32, total: usize, avail: f32) -> usize { - if total == 0 || avail <= 0.0 { - return 0; +/// What the pointer is dragging in the dock, if anything. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DockDrag { + /// Moving pallet `panel`, or with `group` that whole group (`panel` is + /// then its first open pallet, used for the preview). Nothing moves + /// until the pointer has travelled past the drag threshold from + /// `origin`; from then on `target` is where it would land and `grab` the + /// pointer's offset from the dragged frame's top-left corner. + Move { + panel: PanelId, + group: Option<(DockSide, usize)>, + origin: Option, + target: Option, + grab: iced::Vector, + }, + /// Sizing the width of docked `panel`'s group. + Width(PanelId), + /// Moving the splitter between pallets `upper` and `lower` of a group. + Split { + side: DockSide, + group: usize, + upper: usize, + lower: usize, + }, + /// Sizing floating `panel` from its bottom-left (`from_left`) or + /// bottom-right corner. + FloatSize { panel: PanelId, from_left: bool }, + /// Sizing the Layer Manager's Name column. + LayerColumn, +} + +impl DockDrag { + /// Start moving `panel` (a whole group with `group`). + pub fn moving(panel: PanelId, group: Option<(DockSide, usize)>) -> Self { + DockDrag::Move { + panel, + group, + origin: None, + target: None, + grab: iced::Vector::new(0.0, 0.0), + } } - let h = avail / total as f32; - let idx = (y / h).round() as usize; - // A single full-height panel must not offer a top/bottom split: any drop - // lands in the one available slot. - if total <= 1 { - return 0; + + /// The pallet being moved, the group if a whole group moves, and where + /// it would land (once past the drag threshold). + pub fn movement(&self) -> Option<(PanelId, Option<(DockSide, usize)>, Option)> { + match *self { + DockDrag::Move { + panel, + group, + target, + .. + } => Some((panel, group, target)), + _ => None, + } } - idx.min(total) +} + +/// Where a dragged panel lands on release. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DropTarget { + /// A new group on `side` at insertion `index` (0 = first, len = last). + Edge { side: DockSide, index: usize }, + /// Stacked into group `group` on `side` at position `index` (counted + /// before the move; 0 = top). + Join { + side: DockSide, + group: usize, + index: usize, + }, + /// Floating with its top-left corner at (`x`, `y`). + Float { x: f32, y: f32 }, +} + +/// Split `avail` pixels of height between stacked pallets of `weights`, +/// returning each one's `(top, bottom)`. +pub fn slot_spans(weights: &[f32], avail: f32) -> Vec<(f32, f32)> { + let total: f32 = weights.iter().sum(); + if total <= 0.0 { + return Vec::new(); + } + let mut y = 0.0; + weights + .iter() + .map(|w| { + let top = y; + y += avail * w / total; + (top, y) + }) + .collect() +} + +/// Integer share for iced's `FillPortion` from a pallet's weight. +pub fn portion(weight: f32) -> u16 { + (weight * 1000.0).round().clamp(1.0, u16::MAX as f32) as u16 +} + +/// `width` clamped to what fits on screen: at least [`DOCK_MIN_W`], at most +/// `max` and 45 % of a `win_w` wide window. +fn on_screen_width(width: f32, max: f32, win_w: f32) -> f32 { + width.clamp(DOCK_MIN_W, max.min(win_w * 0.45).max(DOCK_MIN_W)) } /// Smallest docked width a panel may be dragged or sized to. pub const DOCK_MIN_W: f32 = 200.0; /// Largest docked width a panel may be dragged or sized to. pub const DOCK_MAX_W: f32 = 600.0; +/// Width of the band along an empty edge that docks a dragged panel there. +pub const DOCK_EDGE_ZONE: f32 = 48.0; +/// Width of the grabbable divider between a docked group and the viewport. +pub const DOCK_DIVIDER_W: f32 = 5.0; +/// Width of an edge's vertical icon strip. +pub const DOCK_STRIP_W: f32 = 40.0; +/// Height of one pallet icon in the strip. +pub const STRIP_CELL_H: f32 = 32.0; +/// Space above and below a group's icons, inside the group. +pub const STRIP_PAD: f32 = 6.0; +/// Hairline divider between groups. +pub const STRIP_DIVIDER_H: f32 = 1.0; +/// Space between the last group and the + button. +pub const STRIP_PLUS_GAP: f32 = 8.0; +/// Width of the band along a group's window-side edge that drags the whole +/// group (the icons drag single pallets). +pub const STRIP_GRIP_W: f32 = 8.0; +/// How far the "new group" zone around a divider reaches into the icons on +/// either side. +const STRIP_NEW_GROUP_SLOP: f32 = 4.0; + +/// Where one group sits in an edge's icon strip, top to bottom. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StripGroup { + /// Group index on the edge. + pub group: usize, + /// Top of the group's row (just below the divider above it). + pub top: f32, + /// Top of the group's first icon. + pub icons_top: f32, + /// Number of icons (open pallets). + pub icons: usize, +} + +impl StripGroup { + /// Bottom of the group's last icon. + pub fn icons_bottom(&self) -> f32 { + self.icons_top + self.icons as f32 * STRIP_CELL_H + } + + /// Bottom of the group's row (where its divider starts). + pub fn bottom(&self) -> f32 { + self.icons_bottom() + STRIP_PAD + } +} + +/// Lay out the icon strip for `groups` = (group index, open pallet count), +/// returning each group's place and the top of the + button below them. +pub fn strip_layout(groups: &[(usize, usize)]) -> (Vec, f32) { + let mut y = 0.0; + let placed = groups + .iter() + .map(|&(group, icons)| { + let g = StripGroup { + group, + top: y, + icons_top: y + STRIP_PAD, + icons, + }; + y = g.bottom() + STRIP_DIVIDER_H; + g + }) + .collect(); + (placed, y + STRIP_PLUS_GAP) +} + +/// What a dragged pallet released at height `y` over the icon strip joins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StripHit { + /// A new group before group `group`; `None` = after the last group. + NewGroup(Option), + /// Join group `group`, at position `index` among its open pallets. + Join { group: usize, index: usize }, +} + +/// Classify a pointer at strip height `y`: on a divider (or the gap above +/// the first group) starts a new group there, over a group's icons joins it +/// between the nearest icons, below every group starts a new last group. +pub fn strip_hit(y: f32, layout: &[StripGroup]) -> StripHit { + for g in layout { + if y < g.icons_top + STRIP_NEW_GROUP_SLOP { + return StripHit::NewGroup(Some(g.group)); + } + if y < g.icons_bottom() - STRIP_NEW_GROUP_SLOP { + let index = (((y - g.icons_top) / STRIP_CELL_H).round().max(0.0) as usize).min(g.icons); + return StripHit::Join { + group: g.group, + index, + }; + } + } + StripHit::NewGroup(None) +} // ── Shared panel chrome ───────────────────────────────────────────────── @@ -277,34 +1037,87 @@ use crate::app::Message; use iced::widget::{button, container, mouse_area, row, text, tooltip, Space}; use iced::{Background, Border, Color, Element, Length, Theme}; +/// How a panel is framed where it is shown, passed to its view so the shared +/// chrome can adapt. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Chrome { + /// The panel auto-collapses (its pin is on). + pub auto_collapse: bool, + /// The panel floats; its title lives in the vertical side bar drawn by + /// the floating frame instead of the horizontal title bar. + pub floating: bool, + /// The pointer is over the title bar, which shows its pin and close. + pub title_hovered: bool, +} + /// The row every docked panel starts with: its title, the auto-collapse pin -/// and close. Pressing the row starts re-docking the panel. -pub fn title_bar<'a>(id: PanelId, title: String, auto_collapse: bool) -> Element<'a, Message> { - let pin_icon = if auto_collapse { - crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) - } else { - crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) - }; - let pin = button(pin_icon) - .on_press(Message::Dock(DockMsg::AutoCollapseToggle(id))) - .style(move |theme: &Theme, status| { - let mut style = button::subtle(theme, status); - if auto_collapse { - let palette = theme.palette(); - style.background = Some(Background::Color(palette.primary.weak.color)); - style.text_color = palette.primary.weak.text; - style.border.color = palette.primary.base.color; - style.border.width = 1.0; - } - style - }) - .padding([3, 5]); - let pin = tooltip( - pin, - text(crate::t!("Auto")).size(10), - tooltip::Position::Bottom, +/// and close. Pressing the row starts re-docking the panel. A floating panel +/// gets nothing here: its frame draws a vertical title bar beside it. +pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, Message> { + if chrome.floating { + return Space::new().width(0).height(0).into(); + } + // The pin and close only show while the pointer is over the bar; the + // fixed height keeps the bar from jumping when they appear. + let mut bar = row![text(title).size(12), Space::new().width(Length::Fill)] + .spacing(3) + .height(Length::Fixed(TITLE_BUTTON_H)) + .align_y(iced::Center); + if chrome.title_hovered { + bar = bar + .push(pin_button( + id, + chrome.auto_collapse, + tooltip::Position::Bottom, + )) + .push(close_button(id, tooltip::Position::Bottom)); + } + mouse_area( + container(bar) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(Length::Fill) + .padding([3, 6]), ) - .gap(4); + .on_press(Message::Dock(DockMsg::DockGrab(id))) + .on_double_click(Message::Dock(DockMsg::FloatOut(id))) + .on_enter(Message::Dock(DockMsg::TitleHover(id))) + .on_exit(Message::Dock(DockMsg::TitleHoverEnd(id))) + .interaction(iced::mouse::Interaction::Grab) + .into() +} + +/// Height of a title bar's button row. +const TITLE_BUTTON_H: f32 = 20.0; + +/// The auto-hide pin. While auto-hide is on the pin is drawn tilted instead +/// of on a coloured background. +pub fn pin_button<'a>( + id: PanelId, + auto_collapse: bool, + tip: tooltip::Position, +) -> Element<'a, Message> { + let icon = crate::ui::icons::themed_secondary( + if auto_collapse { + crate::ui::icons::PIN_ACTIVE + } else { + crate::ui::icons::PIN + }, + 12.0, + ); + let pin = button(icon) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle(id))) + .style(button::subtle) + .padding([3, 5]); + tooltip(pin, text(crate::t!("Auto-hide")).size(10), tip) + .gap(4) + .into() +} + +/// The close button of a panel's title bar. +pub fn close_button<'a>(id: PanelId, tip: tooltip::Position) -> Element<'a, Message> { let close = button(crate::ui::icons::themed_secondary( crate::ui::icons::CLOSE, 12.0, @@ -312,33 +1125,9 @@ pub fn title_bar<'a>(id: PanelId, title: String, auto_collapse: bool) -> Element .on_press(Message::Dock(DockMsg::Close(id))) .style(button::subtle) .padding([3, 5]); - let close = tooltip( - close, - text(crate::t!("Close")).size(10), - tooltip::Position::Bottom, - ) - .gap(4); - mouse_area( - container( - row![ - text(title).size(12), - Space::new().width(Length::Fill), - pin, - close - ] - .spacing(3) - .align_y(iced::Center), - ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), - ..Default::default() - }) - .width(Length::Fill) - .padding([3, 6]), - ) - .on_press(Message::Dock(DockMsg::DockGrab(id))) - .interaction(iced::mouse::Interaction::Grab) - .into() + tooltip(close, text(crate::t!("Close")).size(10), tip) + .gap(4) + .into() } /// Side length of a panel toolbar button's icon. @@ -375,23 +1164,30 @@ pub fn tool_button<'a>( .into() } -/// A docked panel's body: fixed width, full height, padded, on the base -/// background with a neutral edge. -pub fn frame<'a>(content: impl Into>, width: f32) -> Element<'a, Message> { - container(content) - .padding(6) - .width(Length::Fixed(width)) - .height(Length::Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - border: Border { - color: theme.palette().background.neutral.color, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }) - .into() +/// A docked panel's body: its title bar flush along the top (as on the +/// Properties panel), then the padded content; fixed width, full height, on +/// the base background with a neutral edge. +pub fn frame<'a>( + title_bar: Element<'a, Message>, + content: impl Into>, + width: f32, +) -> Element<'a, Message> { + container(iced::widget::column![ + title_bar, + container(content).padding(6).height(Length::Fill) + ]) + .width(Length::Fixed(width)) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() } #[cfg(test)] @@ -405,14 +1201,15 @@ mod tests { // Wider default for the column-rich references table, double the maximum. // (The 45%-of-window rule still dominates on narrow windows.) assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), 460.0); - state.set_width(PanelId::ExternalReferences, 5000.0); + state.dock(PanelId::ExternalReferences, DockSide::Right, 1); + state.set_group_width(DockSide::Right, 1, 5000.0); assert_eq!( - state.width(PanelId::ExternalReferences, 3000.0), + state.group_width_px(DockSide::Right, 1, 3000.0), DOCK_MAX_W * 2.0 ); // Other panels keep the shared maximum. - state.set_width(PanelId::BlockPalette, 5000.0); - assert_eq!(state.width(PanelId::BlockPalette, 3000.0), DOCK_MAX_W); + state.set_group_width(DockSide::Right, 0, 5000.0); + assert_eq!(state.group_width_px(DockSide::Right, 0, 3000.0), DOCK_MAX_W); } #[test] @@ -466,22 +1263,13 @@ mod tests { assert!(!state.dock(PanelId::Properties, DockSide::Left, 0)); } - #[test] - fn set_width_clamps() { - let mut state = DockState::default(); - state.set_width(PanelId::BlockPalette, 10.0); - assert_eq!(state.width(PanelId::BlockPalette, 1600.0), DOCK_MIN_W); - state.set_width(PanelId::BlockPalette, 5000.0); - assert_eq!(state.width(PanelId::BlockPalette, 1600.0), DOCK_MAX_W); - } - #[test] fn width_respects_maximum_window_fraction() { let mut state = DockState::default(); - state.set_width(PanelId::BlockPalette, 500.0); + state.set_group_width(DockSide::Right, 0, 500.0); // Window too narrow -> capped by the 0.45 fraction, not DOCK_MAX_W. assert_eq!( - state.width(PanelId::BlockPalette, 800.0), + state.group_width_px(DockSide::Right, 0, 800.0), DOCK_MAX_W.min(360.0) ); } @@ -489,38 +1277,280 @@ mod tests { #[test] fn width_does_not_panic_when_window_minimized() { let mut state = DockState::default(); - state.set_width(PanelId::BlockPalette, 500.0); + state.set_group_width(DockSide::Right, 0, 500.0); // A minimized or not-yet-laid-out window reports width 0; the clamp // must fall back to DOCK_MIN_W instead of panicking on min > max. - assert_eq!(state.width(PanelId::BlockPalette, 0.0), DOCK_MIN_W); + assert_eq!(state.group_width_px(DockSide::Right, 0, 0.0), DOCK_MIN_W); // Deleted right below the minimum dock width behaves the same way. - assert_eq!(state.width(PanelId::BlockPalette, 100.0), DOCK_MIN_W); + assert_eq!(state.group_width_px(DockSide::Right, 0, 100.0), DOCK_MIN_W); } #[test] - fn drop_index_maps_between_zero_and_total() { - assert_eq!(drop_index(0.0, 3, 300.0), 0); - assert_eq!(drop_index(100.0, 3, 300.0), 1); - assert_eq!(drop_index(299.0, 3, 300.0), 3); - assert_eq!(drop_index(300.0, 3, 300.0), 3); - assert_eq!(drop_index(50.0, 0, 300.0), 0); + fn legacy_bare_ids_load_as_one_stacked_group() { + // Before groups, every id on an edge was shown stacked; keep that. + let json = r#"{"left":["properties","block_palette"],"right":[]}"#; + let state: DockState = serde_json::from_str(json).unwrap(); + assert_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.left[0].weights, vec![1.0, 1.0]); + assert!(state.floating.is_empty()); } #[test] - fn drop_index_single_slot_is_always_zero() { - // One panel sharing the full edge height: any pointer y lands in the - // single slot, so the insertion index must be 0 (no top/bottom split). - assert_eq!(drop_index(5.0, 1, 900.0), 0); - assert_eq!(drop_index(450.0, 1, 900.0), 0); - assert_eq!(drop_index(895.0, 1, 900.0), 0); + fn layout_round_trips_through_serde() { + let mut state = DockState::default(); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + state.dock(PanelId::Count, DockSide::Left, 1); + state.float(FloatPanel::new( + PanelId::SheetSetManager, + 10.0, + 20.0, + 300.0, + 400.0, + )); + let json = serde_json::to_string(&state).unwrap(); + let back: DockState = serde_json::from_str(&json).unwrap(); + assert_eq!(back, state); } #[test] - fn drop_index_multi_slot_maps_position() { - // Two panels on an edge produce insertion positions 0..=total: pointer - // near the top lands before the first slot (0); pointer near the bottom - // of the last slot lands after the last (== total, append). - assert_eq!(drop_index(10.0, 2, 900.0), 0); - assert_eq!(drop_index(890.0, 2, 900.0), 2); + fn join_group_stacks_and_shows_the_group() { + let mut state = DockState::default(); + state.set_group_width(DockSide::Left, 0, 320.0); + // Below Properties in the left group. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); + assert!(state.right.is_empty(), "its old group disappears"); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.left[0].weights.len(), 2); + // The group keeps its width. + assert_eq!(state.group_width(DockSide::Left, 0), 320.0); + // Moving it above Properties keeps it in the group. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 0)); + assert_eq!(state.left[0].panels[0], PanelId::BlockPalette); + // Dropping it where it already is changes nothing. + assert!(!state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1)); + // Nor does dropping a pallet that is alone in its group onto it. + state.dock(PanelId::Count, DockSide::Right, 0); + for index in 0..=1 { + assert!(!state.join_group(PanelId::Count, DockSide::Right, 0, index)); + } + assert_eq!(state.right[0].panels, vec![PanelId::Count]); + } + + #[test] + fn join_group_index_counts_groups_before_the_move() { + let mut state = DockState::default(); + state.left = vec![ + DockGroup::single(PanelId::BlockPalette), + DockGroup::single(PanelId::Properties), + ]; + state.right.clear(); + // Group 1 (Properties) is group 0 once the palette's group is gone. + assert!(state.join_group(PanelId::BlockPalette, DockSide::Left, 1, 1)); + assert_eq!(state.left.len(), 1); + assert_eq!( + state.left[0].panels, + vec![PanelId::Properties, PanelId::BlockPalette] + ); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn dock_starts_a_new_group_and_shows_it() { + let mut state = DockState::default(); + state.join_group(PanelId::BlockPalette, DockSide::Left, 0, 1); + assert!(state.dock(PanelId::BlockPalette, DockSide::Left, 1)); + assert_eq!(state.left.len(), 2); + assert_eq!(state.left[0].panels, vec![PanelId::Properties]); + assert_eq!(state.left[1].panels, vec![PanelId::BlockPalette]); + assert_eq!(state.shown(DockSide::Left), 1); + assert!(state.show_group_of(PanelId::Properties)); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn removing_the_shown_group_keeps_a_valid_shown_index() { + let mut state = DockState::default(); + state.dock(PanelId::Browser, DockSide::Left, 1); + assert_eq!(state.shown(DockSide::Left), 1); + // Floating Browser removes the last group; the edge shows group 0. + state.float(FloatPanel::new(PanelId::Browser, 0.0, 0.0, 250.0, 300.0)); + assert_eq!(state.left.len(), 1); + assert_eq!(state.shown(DockSide::Left), 0); + } + + #[test] + fn move_group_reorders_and_crosses_edges() { + let mut state = DockState::default(); + state.left.push(DockGroup::single(PanelId::Browser)); + state.left.push(DockGroup::single(PanelId::Count)); + // Same spot (before itself or after itself) changes nothing. + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 1)); + assert!(!state.move_group(DockSide::Left, 1, DockSide::Left, 2)); + assert!(state.move_group(DockSide::Left, 0, DockSide::Left, 3)); + assert_eq!(state.left[2].panels, vec![PanelId::Properties]); + assert_eq!(state.shown(DockSide::Left), 2, "the moved group shows"); + assert!(state.move_group(DockSide::Left, 2, DockSide::Right, 0)); + assert_eq!(state.right[0].panels, vec![PanelId::Properties]); + assert_eq!(state.left.len(), 2); + assert!(state.shown(DockSide::Left) < 2); + } + + #[test] + fn shift_split_keeps_combined_share_and_minimum() { + let mut state = DockState::default(); + state.left = vec![DockGroup::stack(vec![ + PanelId::BlockPalette, + PanelId::Properties, + ])]; + state.shift_split(DockSide::Left, 0, 0, 1, 0.5, 0.1); + assert_eq!(state.left[0].weights, vec![1.5, 0.5]); + state.shift_split(DockSide::Left, 0, 0, 1, 10.0, 0.1); + assert!((state.left[0].weights[1] - 0.1).abs() < 1e-6); + assert!((state.left[0].weights.iter().sum::() - 2.0).abs() < 1e-6); + state.reset_splits(DockSide::Left, 0); + assert_eq!(state.left[0].weights, vec![1.0, 1.0]); + } + + #[test] + fn group_width_is_its_own_once_set() { + let mut state = DockState::default(); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + // Unset: the widest pallet (Properties 250 > Browser 230). + assert_eq!(state.group_width(DockSide::Left, 0), 250.0); + state.set_group_width(DockSide::Left, 0, 333.0); + assert_eq!(state.group_width(DockSide::Left, 0), 333.0); + // Pallet widths are untouched; other groups keep theirs. + assert_eq!(state.settings(PanelId::Properties).width, 250.0); + assert_eq!(state.group_width(DockSide::Right, 0), 260.0); + state.set_group_width(DockSide::Left, 0, 5000.0); + assert_eq!(state.group_width(DockSide::Left, 0), DOCK_MAX_W); + state.set_group_width(DockSide::Left, 0, 10.0); + assert_eq!(state.group_width(DockSide::Left, 0), DOCK_MIN_W); + state.reset_group_width(DockSide::Left, 0); + assert_eq!(state.group_width(DockSide::Left, 0), 250.0); + } + + #[test] + fn auto_hide_is_per_edge_for_docked_and_per_pallet_when_floating() { + let mut state = DockState::default(); + state.join_group(PanelId::Browser, DockSide::Left, 0, 1); + assert!(state.toggle_auto_hide(PanelId::Properties)); + // The whole left edge auto-hides, the right edge does not. + assert!(state.auto_hides(PanelId::Browser)); + assert!(!state.auto_hides(PanelId::BlockPalette)); + // A floating pallet keeps its own setting. + state.float(FloatPanel::new(PanelId::Count, 0.0, 0.0, 250.0, 300.0)); + assert!(!state.auto_hides(PanelId::Count)); + assert!(state.toggle_auto_hide(PanelId::Count)); + assert!(state.auto_hides(PanelId::Count)); + assert!(!state.edge_auto_hide(DockSide::Right)); + } + + #[test] + fn ensure_settings_drops_duplicates_and_clamps_the_shown_group() { + let mut state = DockState::default(); + state.right.push(DockGroup::single(PanelId::Properties)); + state.shown = (5, 7); + state.ensure_settings(); + assert_eq!( + state.location(PanelId::Properties), + Some((DockSide::Left, 0)) + ); + assert_eq!(state.right.len(), 1); + assert_eq!(state.shown, (0, 0)); + } + + #[test] + fn strip_hit_maps_dividers_icons_and_the_end() { + // Two groups: three icons, then one. + let (layout, plus_top) = strip_layout(&[(0, 3), (2, 1)]); + let g0 = layout[0]; + let g1 = layout[1]; + assert_eq!(g0.icons_top, STRIP_PAD); + assert_eq!(g1.top, g0.bottom() + STRIP_DIVIDER_H); + assert_eq!(plus_top, g1.bottom() + STRIP_DIVIDER_H + STRIP_PLUS_GAP); + // Above the first icon: a new group before group 0. + assert_eq!(strip_hit(2.0, &layout), StripHit::NewGroup(Some(0))); + // Top half of the first icon: into group 0 at the top. + assert_eq!( + strip_hit(g0.icons_top + 10.0, &layout), + StripHit::Join { group: 0, index: 0 } + ); + // Between the first and second icon of group 0. + assert_eq!( + strip_hit(g0.icons_top + STRIP_CELL_H + 4.0, &layout), + StripHit::Join { group: 0, index: 1 } + ); + // Lower half of the last icon: into group 0 at the end. + assert_eq!( + strip_hit(g0.icons_bottom() - 8.0, &layout), + StripHit::Join { group: 0, index: 3 } + ); + // On the divider between the groups: a new group before group 2. + assert_eq!(strip_hit(g0.bottom(), &layout), StripHit::NewGroup(Some(2))); + // Below everything: a new last group. + assert_eq!( + strip_hit(plus_top + 20.0, &layout), + StripHit::NewGroup(None) + ); + } + + #[test] + fn float_detaches_and_dock_brings_it_back() { + let mut state = DockState::default(); + let rect = FloatPanel::new(PanelId::Properties, 100.0, 50.0, 250.0, 300.0); + assert!(state.float(rect)); + assert!(state.left.is_empty()); + assert_eq!(state.location(PanelId::Properties), None); + assert_eq!(state.float_rect(PanelId::Properties), Some(rect)); + assert!(state.is_placed(PanelId::Properties)); + assert!(state.dock(PanelId::Properties, DockSide::Right, 0)); + assert!(state.floating.is_empty()); + assert_eq!( + state.location(PanelId::Properties), + Some((DockSide::Right, 0)) + ); + } + + #[test] + fn float_resize_respects_minimums() { + let mut state = DockState::default(); + state.float(FloatPanel::new(PanelId::Count, 0.0, 0.0, 300.0, 300.0)); + state.resize_float(PanelId::Count, 10.0, 10.0, false); + let f = state.float_rect(PanelId::Count).unwrap(); + assert_eq!((f.w, f.h), (DOCK_MIN_W, FLOAT_MIN_H)); + // From the left corner the right edge stays where it was. + state.resize_float(PanelId::Count, 350.0, 300.0, true); + let f = state.float_rect(PanelId::Count).unwrap(); + assert_eq!((f.x, f.w), (DOCK_MIN_W - 350.0, 350.0)); + } + + #[test] + fn floating_groups_from_an_older_build_load_as_their_first_pallet() { + let json = r#"{"floating":[{"panels":["count","browser"],"weights":[1.0,1.0],"width":0.0,"x":1.0,"y":2.0,"w":300.0,"h":400.0,"docking":false}]}"#; + let state: DockState = serde_json::from_str(json).unwrap(); + let expected = FloatPanel { + docking: false, + ..FloatPanel::new(PanelId::Count, 1.0, 2.0, 300.0, 400.0) + }; + assert_eq!(state.floating, vec![expected]); + } + + #[test] + fn every_panel_has_its_own_icon() { + let ids = PanelId::ALL; + for (i, a) in ids.iter().enumerate() { + assert!(a.icon().starts_with(b" Element<'_, Message> { + pub fn panel(&self, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> { use crate::ui::dock::{self, PanelId}; let tool = |icon: &'static [u8], tip: &str, message: GraphMsg| { dock::tool_button( @@ -740,16 +740,8 @@ impl Graph { .height(Length::Fill); dock::frame( - column![ - dock::title_bar( - PanelId::NodeGraph, - t!("Node Graph").into_owned(), - auto_collapse - ), - toolbar, - body - ] - .spacing(6), + dock::title_bar(PanelId::NodeGraph, t!("Node Graph").into_owned(), chrome), + column![toolbar, body].spacing(6), width, ) } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index ede999b1d..ff7235eea 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -544,14 +544,11 @@ impl PropertiesPanel { .unwrap_or_default() } - pub fn view(&self, width: f32, auto_collapse: bool) -> Element<'_, Message> { + pub fn view(&self, width: f32, chrome: crate::ui::dock::Chrome) -> Element<'_, Message> { use crate::ui::dock::PanelId; // ── Header ────────────────────────────────────────────────────────── - let header = crate::ui::dock::title_bar( - PanelId::Properties, - t!("Properties").into_owned(), - auto_collapse, - ); + let header = + crate::ui::dock::title_bar(PanelId::Properties, t!("Properties").into_owned(), chrome); // ── Title bar (entity type / "No Selection") ───────────────────── let title_content: Element<'_, Message> = if self.selection_groups.is_empty() { diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index 3bd7d6274..7c1c6c221 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -800,11 +800,15 @@ fn options_card<'a>(palette: &'a BlockPalette) -> Element<'a, Message> { } /// Build the docked panel element from the palette state. -pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element<'_, Message> { +pub fn view( + palette: &BlockPalette, + width: f32, + chrome: crate::ui::dock::Chrome, +) -> Element<'_, Message> { let title_bar = crate::ui::dock::title_bar( PanelId::BlockPalette, crate::t!("Blocks").into_owned(), - auto_collapse, + chrome, ); let tabs = { @@ -991,7 +995,8 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< }; crate::ui::dock::frame( - column![title_bar, top, body, options_card(palette)].spacing(6), + title_bar, + column![top, body, options_card(palette)].spacing(6), width, ) } diff --git a/src/ui/window/browser.rs b/src/ui/window/browser.rs index fcc985e21..b2a555415 100644 --- a/src/ui/window/browser.rs +++ b/src/ui/window/browser.rs @@ -100,13 +100,10 @@ pub fn view<'a>( document: &'a CadDocument, open_sketch: Option<&'a str>, width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, ) -> Element<'a, Message> { - let title_bar = crate::ui::dock::title_bar( - PanelId::Browser, - crate::t!("Browser").into_owned(), - auto_collapse, - ); + let title_bar = + crate::ui::dock::title_bar(PanelId::Browser, crate::t!("Browser").into_owned(), chrome); let mut tree = column![].spacing(1); @@ -151,5 +148,5 @@ pub fn view<'a>( .width(Fill) .height(Fill); - crate::ui::dock::frame(column![title_bar, body].spacing(6), width) + crate::ui::dock::frame(title_bar, column![body].spacing(6), width) } diff --git a/src/ui/window/count_palette.rs b/src/ui/window/count_palette.rs index c5a7ab4e3..bfc36250c 100644 --- a/src/ui/window/count_palette.rs +++ b/src/ui/window/count_palette.rs @@ -660,13 +660,10 @@ pub fn view<'a>( doc_id: u64, epoch: u64, width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, ) -> Element<'a, Message> { - let title_bar = crate::ui::dock::title_bar( - PanelId::Count, - crate::t!("Count").into_owned(), - auto_collapse, - ); + let title_bar = + crate::ui::dock::title_bar(PanelId::Count, crate::t!("Count").into_owned(), chrome); let body = match mode { Some(mode) if mode.target.is_some() => mode_view(palette, mode, doc), _ => { @@ -685,7 +682,7 @@ pub fn view<'a>( list_view(palette, &instances) } }; - crate::ui::dock::frame(column![title_bar, body].spacing(6), width) + crate::ui::dock::frame(title_bar, column![body].spacing(6), width) } fn bar_button<'a>( diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index de7685c70..5b993b7e6 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -337,20 +337,40 @@ impl LayerPanel { .collect() } - /// Render the layer panel as the full content of its own OS window. - pub fn view_window( + /// The Layer Manager as a dock pallet: its title bar, the toolbar across + /// the pallet (search box on the right), then the table, at least as wide + /// as the pallet and scrolling sideways when the pallet is narrower. + pub fn view_panel( &self, name_col_w: f32, - sizing: crate::ui::modal::ModalSizing, + width: f32, + chrome: crate::ui::dock::Chrome, ) -> Element<'_, Message> { - self.view_content(name_col_w, sizing) + use crate::ui::dock::PanelId; + let title_bar = crate::ui::dock::title_bar( + PanelId::Layers, + crate::tr!("modal", "layer-manager"), + chrome, + ); + // Inside the pallet's 1 px border on either side. + let body = self.view_content(name_col_w, (width - 2.0).max(0.0)); + container(column![title_bar, body]) + .width(Length::Fixed(width)) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() } - fn view_content( - &self, - name_col_w: f32, - sizing: crate::ui::modal::ModalSizing, - ) -> Element<'_, Message> { + /// Toolbar and table for a pallet `inner_w` wide. + fn view_content(&self, name_col_w: f32, inner_w: f32) -> Element<'_, Message> { let has_sel = self.selected.is_some(); let sel_is_zero = self .selected @@ -377,7 +397,7 @@ impl LayerPanel { Message::LayerSetCurrent, can_set_current, ), - iced::widget::Space::new().width(sizing.width), + iced::widget::Space::new().width(Length::Fill), // Search box: filters rows by name as the user types (#343). text_input(t!("Search…").as_ref(), &self.filter) .on_input(Message::LayerManagerFilterChanged) @@ -393,7 +413,7 @@ impl LayerPanel { background: Some(Background::Color(theme.palette().background.weak.color)), ..Default::default() }) - .width(sizing.width) + .width(Length::Fill) .padding([4, 8]); // ── Column header ───────────────────────────────────────────────── @@ -473,7 +493,6 @@ impl LayerPanel { ), ] .spacing(4) - .width(sizing.width) .align_y(iced::Center); for vp in &self.vp_cols { @@ -499,7 +518,7 @@ impl LayerPanel { } }) .padding([4, 8]) - .width(sizing.width); + .width(Length::Fill); // ── Layer rows ──────────────────────────────────────────────────── let mut rows_col = column![].spacing(0); @@ -536,17 +555,28 @@ impl LayerPanel { let table = scrollable(rows_col) .id(iced::advanced::widget::Id::new(LAYER_TABLE_SCROLL_ID)) - .height(sizing.height.min(240.0)); + .width(Length::Fill) + .height(Length::Fill); - // ── Full-window frame ───────────────────────────────────────────── - container(column![toolbar, col_header, table].spacing(0)) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.base.color)), - ..Default::default() - }) - .width(sizing.width) - .height(sizing.height) - .into() + // The header and table stretch to the wider of their columns and the + // pallet (the zero-height spacer), so the header's grey band and + // borders always run to the pallet's edge; a narrower pallet scrolls + // the table sideways while the toolbar stays put. + let grid = column![ + col_header, + table, + iced::widget::Space::new() + .width(Length::Fixed(inner_w)) + .height(0), + ] + .height(Length::Fill); + let grid = scrollable(grid) + .direction(iced::widget::scrollable::Direction::Horizontal( + iced::widget::scrollable::Scrollbar::new(), + )) + .width(Length::Fill) + .height(Length::Fill); + column![toolbar, grid].height(Length::Fill).into() } } diff --git a/src/ui/window/pc_manager.rs b/src/ui/window/pc_manager.rs index e3b8208e6..a7ec6ff8f 100644 --- a/src/ui/window/pc_manager.rs +++ b/src/ui/window/pc_manager.rs @@ -277,12 +277,12 @@ pub fn view<'a>( document: &'a CadDocument, selected: &[Handle], width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, ) -> Element<'a, Message> { let title_bar = crate::ui::dock::title_bar( PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), - auto_collapse, + chrome, ); let query = state.search.trim().to_lowercase(); let matches = |label: &str| query.is_empty() || label.to_lowercase().contains(&query); @@ -416,5 +416,5 @@ pub fn view<'a>( .padding([4, 8]) .size(12); - crate::ui::dock::frame(column![title_bar, card, search].spacing(6), width) + crate::ui::dock::frame(title_bar, column![card, search].spacing(6), width) } diff --git a/src/ui/window/sheet_set.rs b/src/ui/window/sheet_set.rs index e44ae713e..1b8c8c8b8 100644 --- a/src/ui/window/sheet_set.rs +++ b/src/ui/window/sheet_set.rs @@ -1153,12 +1153,12 @@ fn heading(label: &str) -> Element<'static, Message> { pub fn view<'a>( state: &'a SheetSetManager, width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, ) -> Element<'a, Message> { let title_bar = crate::ui::dock::title_bar( PanelId::SheetSetManager, t!("Sheet Set Manager").into_owned(), - auto_collapse, + chrome, ); let mut picks: Vec = state .sets @@ -1305,7 +1305,8 @@ pub fn view<'a>( .size(10) .style(muted_style); crate::ui::dock::frame( - column![title_bar, combo, tabs, card, hint].spacing(6), + title_bar, + column![combo, tabs, card, hint].spacing(6), width, ) } diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index ff6ed8790..b5b3bbea2 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -590,7 +590,7 @@ impl XrefManagerPanel { pub fn view<'a>( &'a self, width: f32, - auto_collapse: bool, + chrome: crate::ui::dock::Chrome, missing: usize, doc: &'a CadDocument, ) -> Element<'a, Message> { @@ -602,7 +602,7 @@ impl XrefManagerPanel { crate::t!("External References").as_ref(), self.entries.len() ), - auto_collapse, + chrome, ); // Table content width: column widths plus gutters, stretched to the // dock when wider so rows fill the panel; narrower docks sidescroll.