Dock: floating menu at the pointer, hover-only bar buttons, Allow docking, clip pallets

- The floating title bar's right-click menu opens where the pointer is,
  flipped to stay on screen; the Pallets submenu sits beside it.
- Close and pin on a floating title bar only show while it is hovered,
  as on docked title bars.
- New Allow docking check in that menu: off, dragging the window (or a
  pallet out of it) only floats it, never docks it on an edge.
- Floating windows and stacked pallets clip their content, so a pallet
  sized below its content no longer draws outside its frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
Claude committed 2026-10-10 11:18:42 +00:00
1 parent f19948bc07
commit 75fb9e32dd
27 files changed
+161 -25

No files matched your search

+1
View File
@@ -6166,6 +6166,7 @@ count-feature =
.invalid-point = نقطة غير صالحة.
dock =
.allow-docking = السماح بالإرساء
.auto-hide = إخفاء تلقائي
.dock-left = إرساء إلى اليسار
.dock-right = إرساء إلى اليمين
+1
View File
@@ -6154,6 +6154,7 @@ count-feature =
.invalid-point = Невалидна точка.
dock =
.allow-docking = Разреши закачане
.auto-hide = Автоматично скриване
.dock-left = Закачи вляво
.dock-right = Закачи вдясно
+1
View File
@@ -6151,6 +6151,7 @@ count-feature =
.invalid-point = Neplatný bod.
dock =
.allow-docking = Povolit ukotvení
.auto-hide = Automaticky skrýt
.dock-left = Ukotvit vlevo
.dock-right = Ukotvit vpravo
+1
View File
@@ -6152,6 +6152,7 @@ count-feature =
.invalid-point = Ungültiger Punkt.
dock =
.allow-docking = Andocken zulassen
.auto-hide = Automatisch ausblenden
.dock-left = Links andocken
.dock-right = Rechts andocken
+1
View File
@@ -6154,6 +6154,7 @@ count-feature =
.invalid-point = Μη έγκυρο σημείο.
dock =
.allow-docking = Να επιτρέπεται η πρόσδεση
.auto-hide = Αυτόματη απόκρυψη
.dock-left = Πρόσδεση αριστερά
.dock-right = Πρόσδεση δεξιά
+1
View File
@@ -6155,6 +6155,7 @@ count-feature =
.invalid-point = Invalid point.
dock =
.allow-docking = Allow docking
.auto-hide = Auto-hide
.dock-left = Dock left
.dock-right = Dock right
+1
View File
@@ -6154,6 +6154,7 @@ count-feature =
.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
+1
View File
@@ -6151,6 +6151,7 @@ count-feature =
.invalid-point = Virheellinen piste.
dock =
.allow-docking = Salli telakointi
.auto-hide = Piilota automaattisesti
.dock-left = Telakoi vasemmalle
.dock-right = Telakoi oikealle
+1
View File
@@ -6153,6 +6153,7 @@ count-feature =
.invalid-point = Point non valide.
dock =
.allow-docking = Autoriser l'ancrage
.auto-hide = Masquage automatique
.dock-left = Ancrer à gauche
.dock-right = Ancrer à droite
+1
View File
@@ -6142,6 +6142,7 @@ count-feature =
.invalid-point = अमान्य बिंदु।
dock =
.allow-docking = डॉकिंग की अनुमति दें
.auto-hide = स्वतः छिपाएँ
.dock-left = बाएँ डॉक करें
.dock-right = दाएँ डॉक करें
+1
View File
@@ -6153,6 +6153,7 @@ count-feature =
.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
+1
View File
@@ -6153,6 +6153,7 @@ count-feature =
.invalid-point = Punto non valido.
dock =
.allow-docking = Consenti ancoraggio
.auto-hide = Nascondi automaticamente
.dock-left = Ancora a sinistra
.dock-right = Ancora a destra
+1
View File
@@ -6142,6 +6142,7 @@ count-feature =
.invalid-point = 点が無効です。
dock =
.allow-docking = ドッキングを許可
.auto-hide = 自動的に隠す
.dock-left = 左にドッキング
.dock-right = 右にドッキング
+1
View File
@@ -6153,6 +6153,7 @@ count-feature =
.invalid-point = 점이 잘못되었습니다.
dock =
.allow-docking = 도킹 허용
.auto-hide = 자동 숨기기
.dock-left = 왼쪽에 도킹
.dock-right = 오른쪽에 도킹
+1
View File
@@ -6152,6 +6152,7 @@ count-feature =
.invalid-point = Ongeldig punt.
dock =
.allow-docking = Koppelen toestaan
.auto-hide = Automatisch verbergen
.dock-left = Links koppelen
.dock-right = Rechts koppelen
+1
View File
@@ -6153,6 +6153,7 @@ count-feature =
.invalid-point = Nieprawidłowy punkt.
dock =
.allow-docking = Zezwalaj na dokowanie
.auto-hide = Autoukrywanie
.dock-left = Zadokuj po lewej
.dock-right = Zadokuj po prawej
+1
View File
@@ -6154,6 +6154,7 @@ count-feature =
.invalid-point = Ponto inválido.
dock =
.allow-docking = Permitir encaixe
.auto-hide = Ocultar automaticamente
.dock-left = Encaixar à esquerda
.dock-right = Encaixar à direita
+1
View File
@@ -6155,6 +6155,7 @@ count-feature =
.invalid-point = Недопустимая точка.
dock =
.allow-docking = Разрешить закрепление
.auto-hide = Автоскрытие
.dock-left = Закрепить слева
.dock-right = Закрепить справа
+1
View File
@@ -6077,6 +6077,7 @@ count-feature =
.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
+1
View File
@@ -6137,6 +6137,7 @@ count-feature =
.invalid-point = 无效的点。
dock =
.allow-docking = 允许停靠
.auto-hide = 自动隐藏
.dock-left = 停靠到左侧
.dock-right = 停靠到右侧
+1
View File
@@ -6149,6 +6149,7 @@ count-feature =
.invalid-point = 無效的點。
dock =
.allow-docking = 允許停駐
.auto-hide = 自動隱藏
.dock-left = 停駐到左側
.dock-right = 停駐到右側
+17
View File
@@ -628,3 +628,20 @@ fn blockpalette_width_reset() {
let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0);
}
#[test]
fn a_window_without_allow_docking_only_floats_when_dragged() {
use crate::ui::dock::{DockMsg, DropTarget, PanelId};
let mut app = dock_app();
let _ = app.on_dock(DockMsg::FloatOut(PanelId::BlockPalette));
let _ = app.on_dock(DockMsg::FloatDockingToggle(PanelId::BlockPalette));
assert!(!app.dock.float_rect(PanelId::BlockPalette).unwrap().docking);
// Dragged onto the left edge's strip, it stays a floating window.
let _ = app.on_dock(DockMsg::FloatGrab(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);
assert!(app.dock.float_rect(PanelId::BlockPalette).is_some());
assert_eq!(app.dock.location(PanelId::BlockPalette), None);
}
+38 -4
View File
@@ -149,12 +149,20 @@ impl OpenCADStudio {
}
DockMsg::FloatMenu(id) => {
self.dock_float_menu = id;
self.dock_float_menu_at = self.dock_float_pointer;
self.dock_float_menu_pallets = false;
if let Some(id) = id {
self.dock.raise_float(id);
}
}
DockMsg::FloatMenuPallets(open) => self.dock_float_menu_pallets = open,
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::FloatMenuToggle(window, id) => {
// Checked = open in this window: unchecking hides it.
// Anything else (closed, docked, in another window) moves
@@ -289,7 +297,13 @@ impl OpenCADStudio {
}
DropTarget::Float { x, y } => {
let (w, h) = self.dock_float_size(panel);
self.dock.float(FloatPanel::new(panel, x, y, w, h))
// Pulled out of a window, it keeps the
// window's 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)
})
}
};
}
@@ -356,9 +370,18 @@ impl OpenCADStudio {
grab
};
let target = if started {
// A window that may not dock (or a pallet pulled out of
// one) only floats.
let floats_only = group.is_none()
&& self.dock.float_rect(panel).is_some_and(|f| !f.docking);
let found = if floats_only {
self.dock_float_target(point, grab)
} else {
self.dock_drop_target(point, grab)
};
// A whole docked group moves between group positions or
// floats; it cannot join another group.
match (group, self.dock_drop_target(point, grab)) {
match (group, found) {
(Some(_), DropTarget::Join { .. }) => None,
(_, found) => Some(found),
}
@@ -786,7 +809,7 @@ impl OpenCADStudio {
) -> crate::ui::dock::DropTarget {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W};
let (ww, wh) = self.dock_workspace_size();
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 {
@@ -841,8 +864,19 @@ impl OpenCADStudio {
}
}
}
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));
DropTarget::Float { x, y }
crate::ui::dock::DropTarget::Float { x, y }
}
}
+44 -20
View File
@@ -140,6 +140,7 @@ impl OpenCADStudio {
.height(Length::FillPortion(crate::ui::dock::portion(
group.weights[pos],
)))
.clip(true)
.into(),
);
prev = Some(pos);
@@ -474,7 +475,8 @@ impl OpenCADStudio {
let (x, y, bar_left, hidden) = self.dock_float_frame(f);
// Messages name the window by its first open pallet.
let anchor = visible[0];
let bar = floating_title_bar(&visible, self.dock.auto_hides(anchor), bar_left);
let hovered = self.dock_title_hover.is_some_and(|p| f.contains(p));
let bar = floating_title_bar(&visible, self.dock.auto_hides(anchor), bar_left, hovered);
let content: Element<'_, Message> = if hidden {
bar
} else {
@@ -511,6 +513,7 @@ impl OpenCADStudio {
pallets.push(
container(self.panel_body(*id, f.w, true, tab))
.height(Length::FillPortion(crate::ui::dock::portion(weight)))
.clip(true)
.into(),
);
prev = Some(pos);
@@ -535,8 +538,11 @@ impl OpenCADStudio {
row![body, bar].into()
}
};
// Clipped, so a pallet squeezed below its content's height never
// draws outside the window.
let framed = container(content)
.height(Length::Fixed(f.h))
.clip(true)
.style(|theme: &Theme| container::Style {
border: Border {
color: theme.palette().background.strong.color,
@@ -561,8 +567,8 @@ impl OpenCADStudio {
}
/// The open right-click menu of a floating window: Pallets (a submenu
/// to add or hide pallets in the window), Dock left and Dock right. It
/// hangs beside the title bar, toward the window's inside.
/// to add or hide pallets in the window), Allow docking, Dock left and
/// Dock right. It opens at the pointer, flipped to stay on screen.
fn dock_float_menu_overlay(&self) -> Option<Element<'_, Message>> {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId};
@@ -571,17 +577,16 @@ impl OpenCADStudio {
if self.dock_float_visible(f).is_empty() {
return None;
}
let (ww, _) = self.dock_workspace_size();
let (ww, wh) = self.dock_workspace_size();
let (x, y, bar_left, hidden) = self.dock_float_frame(f);
let total_w = if hidden { DOCK_FLOAT_BAR_W } else { f.w + DOCK_FLOAT_BAR_W };
let bar_x = if bar_left { x } else { x + total_w - DOCK_FLOAT_BAR_W };
// Where the right-click happened, in the workspace.
let at = iced::Point::new(bar_x + self.dock_float_menu_at.x, y + self.dock_float_menu_at.y);
let max_x = (ww - DOCK_MENU_W).max(0.0);
let menu_x = if bar_left {
bar_x + DOCK_FLOAT_BAR_W + 2.0
} else {
bar_x - DOCK_MENU_W - 2.0
}
.clamp(0.0, max_x);
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, max_x);
let menu_y = at.y.min(wh - FLOAT_MENU_H).max(0.0);
let open = self.dock_float_menu_pallets;
// Hovering a row opens (Pallets) or closes (the others) the submenu.
let item = |label: Element<'static, Message>, press: DockMsg, sub: bool| -> Element<'static, Message> {
@@ -607,9 +612,17 @@ impl OpenCADStudio {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
});
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 main = column![
item(pallets.into(), DockMsg::FloatMenuPallets(!open), true),
container(separator).padding([3, 4]),
item(docking.into(), DockMsg::FloatDockingToggle(anchor), false),
item(
text(t!("Dock left")).size(12).into(),
DockMsg::DockTo(anchor, DockSide::Left),
@@ -623,24 +636,22 @@ impl OpenCADStudio {
]
.spacing(1)
.padding(4);
let mut layers = vec![place_at(menu_frame(main.into()), menu_x, y + 4.0)];
let mut layers = vec![place_at(menu_frame(main.into()), menu_x, menu_y)];
if open {
let checked: Vec<_> = PanelId::ALL
.iter()
.map(|p| (*p, f.contains(*p) && self.dock_panel_visible(*p)))
.collect();
let toggle = move |id| Message::Dock(DockMsg::FloatMenuToggle(anchor, id));
// Beside the menu, on the side it opened toward, unless there is
// no room there.
let right = menu_x + 2.0 * DOCK_MENU_W + 2.0 <= ww;
let left = menu_x - DOCK_MENU_W - 2.0 >= 0.0;
let sub_x = if (bar_left && right) || !left {
// To the right of the menu, or its left when there is no room.
let sub_x = if menu_x + 2.0 * DOCK_MENU_W + 2.0 <= ww {
menu_x + DOCK_MENU_W + 2.0
} else {
menu_x - DOCK_MENU_W - 2.0
}
.clamp(0.0, max_x);
layers.push(place_at(dock_pallet_menu(&checked, toggle), sub_x, y + 4.0));
let sub_y = menu_y.min(wh - PALLET_MENU_H).max(0.0);
layers.push(place_at(dock_pallet_menu(&checked, toggle), sub_x, sub_y));
}
Some(
iced::widget::Stack::with_children(layers)
@@ -954,6 +965,7 @@ fn floating_title_bar(
visible: &[crate::ui::dock::PanelId],
auto_collapse: bool,
on_left: bool,
hovered: bool,
) -> Element<'static, Message> {
use crate::ui::dock::DockMsg;
let anchor = visible[0];
@@ -962,9 +974,14 @@ fn floating_title_bar(
} else {
iced::widget::tooltip::Position::Right
};
let close = crate::ui::dock::close_button(DockMsg::FloatClose(anchor), tip);
let pin = crate::ui::dock::pin_button(anchor, auto_collapse, tip);
let mut bar = column![close, pin].spacing(2);
// 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(DockMsg::FloatClose(anchor), tip))
.push(crate::ui::dock::pin_button(anchor, auto_collapse, tip));
}
if let [id] = visible {
let title = canvas(VBarLabel {
text: t!(id.title()).into_owned(),
@@ -1000,6 +1017,9 @@ fn floating_title_bar(
}))
.on_press(Message::Dock(DockMsg::FloatGrab(anchor)))
.on_right_press(Message::Dock(DockMsg::FloatMenu(Some(anchor))))
.on_enter(Message::Dock(DockMsg::TitleHover(anchor)))
.on_move(|p| Message::Dock(DockMsg::FloatBarPointer(p)))
.on_exit(Message::Dock(DockMsg::TitleHoverEnd(anchor)))
// Double-click docks it against the edge its title bar faces.
.on_double_click(Message::Dock(DockMsg::DockTo(
anchor,
@@ -1058,6 +1078,10 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> {
/// Width of the icon strip's pallet menu.
const DOCK_MENU_W: f32 = 230.0;
/// Rough heights of a floating window's menu and of a pallet menu, to keep
/// them on screen.
const FLOAT_MENU_H: f32 = 150.0;
const PALLET_MENU_H: f32 = 270.0;
/// A pallet menu (an edge's + button, a floating window's Pallets
/// submenu), styled like the right-click menu: one row per pallet with its
+6
View File
@@ -869,6 +869,10 @@ pub(super) struct OpenCADStudio {
/// is open, and whether its Pallets submenu shows.
pub(crate) dock_float_menu: Option<crate::ui::dock::PanelId>,
pub(crate) dock_float_menu_pallets: bool,
/// 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),
@@ -4303,6 +4307,8 @@ impl OpenCADStudio {
dock_hover_gen: 0,
dock_float_menu: None,
dock_float_menu_pallets: false,
dock_float_pointer: iced::Point::ORIGIN,
dock_float_menu_at: iced::Point::ORIGIN,
dock_drag: None,
dock_drag_last: None,
dock_title_hover: None,
+1
View File
@@ -5755,6 +5755,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"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")),
+34 -1
View File
@@ -85,6 +85,12 @@ pub enum DockMsg {
FloatMenu(Option<PanelId>),
/// Show (`true`) or hide the floating menu's Pallets submenu.
FloatMenuPallets(bool),
/// Floating menu's Allow docking check: flip it for the window holding
/// the pallet.
FloatDockingToggle(PanelId),
/// The pointer moved over a floating title bar, at this point in it
/// (where its right-click menu opens).
FloatBarPointer(iced::Point),
/// Pallets submenu pick (window, pallet): hide the pallet when it shows
/// in the window, else add it to the window and open it.
FloatMenuToggle(PanelId, PanelId),
@@ -342,6 +348,9 @@ pub struct FloatPanel {
pub y: f32,
pub w: f32,
pub h: f32,
/// Dragging the window (or a pallet out of it) may dock it on an edge;
/// off, it only ever floats.
pub docking: bool,
}
/// On-disk form of a floating window. Configs from before floating groups
@@ -358,6 +367,12 @@ struct FloatRepr {
y: f32,
w: f32,
h: f32,
#[serde(default = "docking_default")]
docking: bool,
}
fn docking_default() -> bool {
true
}
impl From<FloatRepr> for FloatPanel {
@@ -373,6 +388,7 @@ impl From<FloatRepr> for FloatPanel {
y: r.y,
w: r.w,
h: r.h,
docking: r.docking,
}
}
}
@@ -386,6 +402,7 @@ impl FloatPanel {
y,
w,
h,
docking: true,
}
}
@@ -830,7 +847,14 @@ impl DockState {
}
let g = self.groups_mut(side).remove(group);
self.after_group_removed(side, group);
let mut f = FloatPanel { group: g, x, y, w, h };
let mut f = FloatPanel {
group: g,
x,
y,
w,
h,
docking: true,
};
f.heal();
self.floating.push(f);
true
@@ -902,6 +926,15 @@ impl DockState {
true
}
/// Flip whether dragging the floating window holding `id` may dock it.
pub fn toggle_float_docking(&mut self, id: PanelId) -> bool {
let Some(i) = self.float_index(id) else {
return false;
};
self.floating[i].docking = !self.floating[i].docking;
true
}
/// Bring the floating window holding `id` to the front.
pub fn raise_float(&mut self, id: PanelId) {
if let Some(i) = self.float_index(id) {