forked from 1b-agent/opencadstudio-upstream
Dock: review fixes - hover, preview width, stale indexes, config healing
Fixes: - Hover state no longer drops when the pointer moves up from one strip icon, group grip or title bar to the one above: iced handles the element above first, so the old element's "left" (None) cleared the hover just set. Leave messages now carry the element and only clear their own hover. - The drop preview used the pallets' own widths; it now uses the group's width, matching where the pallet lands. - Group indexes from messages (group grab, splitter) are looked up safely, so a message from before a layout change no longer panics. - Floating placements read from the config are healed (finite, positive, at least the minimum size), like the group weights already were. - The shown group's edge bar is centred in its row: a stack takes its height from its first layer, and the icon column was shorter than the row. The "Drag to move group" tooltip is removed. Simplifications (no behaviour change): - dock_open_at_default / dock_unpeek replace seven copies of "dock on the right if unplaced, then reveal" and four of "stop peeking". - The drop target takes the grab offset as a parameter, so a drag update stores its state once. - dock_set_open, ensure_settings, the shown-index bookkeeping after a group is removed and the on-screen width clamp each lose duplication. - VBarLabel drops options its single caller never varied. - The drop preview's landing computation is one helper. Naming and consistency: pallet names in the + menu, the drag ghost and the floating title bar go through t!(); StripHit::Tab becomes StripHit::Join; stale comments updated. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
11 files changed
+239
-258
No files matched your search
@@ -911,17 +911,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
|
||||
.is_placed(crate::ui::dock::PanelId::ExternalReferences)
|
||||
{
|
||||
self.dock.dock(
|
||||
crate::ui::dock::PanelId::ExternalReferences,
|
||||
crate::app::config::DockSide::Right,
|
||||
usize::MAX,
|
||||
);
|
||||
}
|
||||
self.dock_reveal(crate::ui::dock::PanelId::ExternalReferences);
|
||||
self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences);
|
||||
self.refresh_xref_manager();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,12 +236,9 @@ impl OpenCADStudio {
|
||||
self.count_palette.show = open;
|
||||
self.ribbon.set_count_palette(open);
|
||||
if open {
|
||||
if !self.dock.is_placed(id) {
|
||||
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_reveal(id);
|
||||
} else if self.dock_peek == Some(id) {
|
||||
self.dock_peek = None;
|
||||
self.dock_open_at_default(id);
|
||||
} else {
|
||||
self.dock_unpeek(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -679,15 +679,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.is_placed(id) {
|
||||
// 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_reveal(id);
|
||||
self.dock_open_at_default(id);
|
||||
self.command_line
|
||||
.push_output(crate::t!("Browser opened.").as_ref());
|
||||
} else {
|
||||
|
||||
@@ -374,16 +374,11 @@ impl OpenCADStudio {
|
||||
"POINTCLOUDMANAGER" => {
|
||||
let id = crate::ui::dock::PanelId::PointCloudManager;
|
||||
self.pc_manager.show = true;
|
||||
if !self.dock.is_placed(id) {
|
||||
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_reveal(id);
|
||||
self.dock_open_at_default(id);
|
||||
}
|
||||
"POINTCLOUDMANAGERCLOSE" => {
|
||||
self.pc_manager.show = false;
|
||||
if self.dock_peek == Some(crate::ui::dock::PanelId::PointCloudManager) {
|
||||
self.dock_peek = None;
|
||||
}
|
||||
self.dock_unpeek(crate::ui::dock::PanelId::PointCloudManager);
|
||||
}
|
||||
"_PCUNCROP" =>self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()),
|
||||
"_PDFULUNCLIP" => {
|
||||
|
||||
@@ -577,12 +577,9 @@ impl OpenCADStudio {
|
||||
self.sheet_set.show = on;
|
||||
self.ribbon.set_sheet_set(on);
|
||||
if on {
|
||||
if !self.dock.is_placed(id) {
|
||||
self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_reveal(id);
|
||||
} else if self.dock_peek == Some(id) {
|
||||
self.dock_peek = None;
|
||||
self.dock_open_at_default(id);
|
||||
} else {
|
||||
self.dock_unpeek(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,14 +335,14 @@ fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() {
|
||||
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(Some(PanelId::Browser)));
|
||||
let _ = app.on_dock(DockMsg::Hover(PanelId::Browser));
|
||||
assert!(app.dock_edge_expanded(DockSide::Left));
|
||||
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
|
||||
let _ = app.on_dock(DockMsg::HoverExit);
|
||||
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(Some(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));
|
||||
}
|
||||
|
||||
+81
-71
@@ -55,8 +55,18 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.save_config();
|
||||
}
|
||||
DockMsg::TitleHover(id) => self.dock_title_hover = id,
|
||||
DockMsg::GripHover(grip) => self.dock_grip_hover = grip,
|
||||
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.
|
||||
@@ -114,15 +124,18 @@ impl OpenCADStudio {
|
||||
}
|
||||
DockMsg::Close(id) => return self.dock_set_open(id, false),
|
||||
DockMsg::Hover(id) => {
|
||||
self.dock_icon_hover = id;
|
||||
self.dock_icon_hover = Some(id);
|
||||
// Only an auto-hiding edge (or floating pallet) reacts to
|
||||
// hover: 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 let Some(id) = id {
|
||||
if self.dock_drag.is_none() && self.dock.auto_hides(id) {
|
||||
self.dock_reveal(id);
|
||||
}
|
||||
if self.dock_drag.is_none() && self.dock.auto_hides(id) {
|
||||
self.dock_reveal(id);
|
||||
}
|
||||
}
|
||||
DockMsg::HoverEnd(id) => {
|
||||
if self.dock_icon_hover == Some(id) {
|
||||
self.dock_icon_hover = None;
|
||||
}
|
||||
}
|
||||
DockMsg::HoverExit => {
|
||||
@@ -218,23 +231,15 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
grab
|
||||
};
|
||||
// Store the grab first: the drop target reads it.
|
||||
self.dock_drag = Some(DockDrag::Move {
|
||||
panel,
|
||||
group,
|
||||
origin: Some(origin),
|
||||
target,
|
||||
grab,
|
||||
});
|
||||
if !started {
|
||||
return;
|
||||
}
|
||||
let found = self.dock_drop_target(point);
|
||||
// A whole group only moves between group positions: it
|
||||
// cannot join another group or float.
|
||||
let target = match (group, found) {
|
||||
(Some(_), DropTarget::Edge { .. }) | (None, _) => Some(found),
|
||||
(Some(_), _) => None,
|
||||
let target = if started {
|
||||
// A whole group only moves between group positions: it
|
||||
// cannot join another group or float.
|
||||
match (group, self.dock_drop_target(point, grab)) {
|
||||
(Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found),
|
||||
(Some(_), _) => None,
|
||||
}
|
||||
} else {
|
||||
target
|
||||
};
|
||||
self.dock_drag = Some(DockDrag::Move {
|
||||
panel,
|
||||
@@ -251,11 +256,14 @@ impl OpenCADStudio {
|
||||
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()
|
||||
.map(|(i, _, _)| self.dock.groups(side)[group].weights[*i])
|
||||
.filter_map(|(i, _, _)| weights.get(*i))
|
||||
.sum();
|
||||
if avail > 0.0 && total > 0.0 {
|
||||
let per_px = total / avail;
|
||||
@@ -334,50 +342,33 @@ impl OpenCADStudio {
|
||||
id: crate::ui::dock::PanelId,
|
||||
open: bool,
|
||||
) -> iced::Task<Message> {
|
||||
use crate::ui::dock::PanelId;
|
||||
use crate::ui::dock::{DockDrag, PanelId};
|
||||
let mut task = iced::Task::none();
|
||||
if open {
|
||||
let task = if self.dock_panel_visible(id) {
|
||||
iced::Task::none()
|
||||
} else {
|
||||
if !self.dock_panel_visible(id) {
|
||||
match id {
|
||||
PanelId::Properties => {
|
||||
self.show_properties = true;
|
||||
self.ribbon.set_properties(true);
|
||||
iced::Task::none()
|
||||
}
|
||||
PanelId::BlockPalette => {
|
||||
self.open_blocks_palette(None);
|
||||
iced::Task::none()
|
||||
}
|
||||
PanelId::BlockPalette => self.open_blocks_palette(None),
|
||||
PanelId::ExternalReferences => {
|
||||
self.show_external_references = true;
|
||||
self.refresh_xref_manager();
|
||||
iced::Task::none()
|
||||
}
|
||||
PanelId::Browser => {
|
||||
self.show_browser = true;
|
||||
iced::Task::none()
|
||||
}
|
||||
PanelId::NodeGraph => self.on_graph(crate::ui::node_graph::GraphMsg::Toggle),
|
||||
PanelId::PointCloudManager => {
|
||||
self.pc_manager.show = true;
|
||||
iced::Task::none()
|
||||
}
|
||||
PanelId::Count => {
|
||||
self.set_count_palette(true);
|
||||
iced::Task::none()
|
||||
}
|
||||
PanelId::SheetSetManager => {
|
||||
self.show_sheet_set_manager(true);
|
||||
iced::Task::none()
|
||||
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;
|
||||
iced::Task::none()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
self.dock_reveal(id);
|
||||
return task;
|
||||
}
|
||||
@@ -407,16 +398,32 @@ impl OpenCADStudio {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
if self
|
||||
.dock_drag
|
||||
.is_some_and(|d| d.movement().is_some_and(|(p, _, _)| p == id) || d == crate::ui::dock::DockDrag::Width(id))
|
||||
{
|
||||
self.dock_drag = None;
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
|
||||
/// Whether `id` is currently rendered (not closed, not on the start screen /
|
||||
@@ -457,10 +464,13 @@ impl OpenCADStudio {
|
||||
side: crate::app::config::DockSide,
|
||||
gi: usize,
|
||||
) -> Vec<crate::ui::dock::PanelId> {
|
||||
self.dock.groups(side)[gi]
|
||||
.panels
|
||||
.iter()
|
||||
.copied()
|
||||
// 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()
|
||||
}
|
||||
@@ -563,8 +573,12 @@ impl OpenCADStudio {
|
||||
/// 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.
|
||||
pub(crate) fn dock_drop_target(&self, p: iced::Point) -> crate::ui::dock::DropTarget {
|
||||
/// 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, wh) = self.dock_workspace_size();
|
||||
@@ -591,7 +605,7 @@ impl OpenCADStudio {
|
||||
side,
|
||||
index: at.unwrap_or(self.dock.groups(side).len()),
|
||||
},
|
||||
StripHit::Tab { group, index } => {
|
||||
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);
|
||||
@@ -622,10 +636,6 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
let grab = match self.dock_drag {
|
||||
Some(crate::ui::dock::DockDrag::Move { grab, .. }) => grab,
|
||||
_ => iced::Vector::new(0.0, 0.0),
|
||||
};
|
||||
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 }
|
||||
|
||||
+66
-93
@@ -34,7 +34,7 @@ impl OpenCADStudio {
|
||||
if let Some(side) = self.dock_edge_menu {
|
||||
if !self.dock_visible_groups(side).is_empty() {
|
||||
// Checked = open and docked on this edge.
|
||||
let shown: Vec<_> = crate::ui::dock::PanelId::ALL
|
||||
let checked: Vec<_> = crate::ui::dock::PanelId::ALL
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let here = self.dock.location(*p).map(|(s, _)| s) == Some(side);
|
||||
@@ -55,7 +55,7 @@ impl OpenCADStudio {
|
||||
.on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None)))
|
||||
.into(),
|
||||
);
|
||||
layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top));
|
||||
layers.push(place_at(dock_edge_menu(side, &checked), x.max(0.0), plus_top));
|
||||
}
|
||||
}
|
||||
// While a panel is dragged, preview where it lands.
|
||||
@@ -173,10 +173,6 @@ impl OpenCADStudio {
|
||||
Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group),
|
||||
_ => None,
|
||||
};
|
||||
let tip_side = match side {
|
||||
DockSide::Left => iced::widget::tooltip::Position::Right,
|
||||
DockSide::Right => iced::widget::tooltip::Position::Left,
|
||||
};
|
||||
let mut items: Vec<Element<'_, Message>> = Vec::new();
|
||||
for gi in self.dock_visible_groups(side) {
|
||||
let is_shown = shown == Some(gi);
|
||||
@@ -218,15 +214,13 @@ impl OpenCADStudio {
|
||||
.center_y(Length::Fixed(STRIP_CELL_H)),
|
||||
)
|
||||
.on_press(Message::Dock(DockMsg::IconPress(id)))
|
||||
.on_enter(Message::Dock(DockMsg::Hover(Some(id))))
|
||||
.on_exit(Message::Dock(DockMsg::Hover(None)))
|
||||
.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.
|
||||
// Explicit heights: the tooltip around the band sizes it to its
|
||||
// content, so a `Fill` bar would stop short of the row's bottom.
|
||||
let bar_w = if lit { 5.0 } else { 3.0 };
|
||||
let bar_inset = 5.0;
|
||||
let bar = container(
|
||||
@@ -275,15 +269,9 @@ impl OpenCADStudio {
|
||||
}),
|
||||
)
|
||||
.on_press(Message::Dock(DockMsg::GroupGrab(side, gi)))
|
||||
.on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi)))))
|
||||
.on_exit(Message::Dock(DockMsg::GripHover(None)))
|
||||
.on_enter(Message::Dock(DockMsg::GripHover(side, gi)))
|
||||
.on_exit(Message::Dock(DockMsg::GripHoverEnd(side, gi)))
|
||||
.interaction(iced::mouse::Interaction::Grab);
|
||||
let band = iced::widget::tooltip(
|
||||
band,
|
||||
text(t!("Drag to move group")).size(10),
|
||||
tip_side,
|
||||
)
|
||||
.gap(4);
|
||||
let band = container(band)
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(row_h))
|
||||
@@ -292,7 +280,9 @@ impl OpenCADStudio {
|
||||
DockSide::Right => iced::alignment::Horizontal::Right,
|
||||
});
|
||||
items.push(
|
||||
container(stack![column(icons), band])
|
||||
// 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 {
|
||||
@@ -512,7 +502,7 @@ impl OpenCADStudio {
|
||||
// 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(Some(f.id))))
|
||||
.on_enter(Message::Dock(DockMsg::Hover(f.id)))
|
||||
.on_exit(Message::Dock(DockMsg::HoverExit))
|
||||
.interaction(iced::mouse::Interaction::Idle);
|
||||
// A hidden panel keeps its title bar where it sits when shown.
|
||||
@@ -534,7 +524,7 @@ impl OpenCADStudio {
|
||||
) -> Element<'_, Message> {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::DropTarget;
|
||||
let (ww, avail) = self.dock_workspace_size();
|
||||
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())
|
||||
@@ -556,51 +546,20 @@ impl OpenCADStudio {
|
||||
.into()
|
||||
};
|
||||
let mut layers: Vec<Element<'_, Message>> = Vec::new();
|
||||
let docked = match target {
|
||||
DropTarget::Edge { side, index } => {
|
||||
let mut after = self.dock.clone();
|
||||
after.dock(id, side, index);
|
||||
Some((side, after))
|
||||
}
|
||||
DropTarget::Join { side, group, index } => {
|
||||
let mut after = self.dock.clone();
|
||||
after.join_group(id, side, group, index);
|
||||
Some((side, after))
|
||||
}
|
||||
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,
|
||||
));
|
||||
layers.push(place_at(dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), x, y));
|
||||
None
|
||||
}
|
||||
};
|
||||
// A whole group shows only the strip feedback and its ghost; the
|
||||
// column keeps showing what is there.
|
||||
let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g);
|
||||
let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after)));
|
||||
let marks_side = docked.as_ref().map(|(side, _)| *side);
|
||||
if let Some((side, Some(after))) = docked {
|
||||
// Lay the group out as it will be after the drop.
|
||||
if let Some((_, gi)) = after.location(id) {
|
||||
let group = &after.groups(side)[gi];
|
||||
let open: Vec<usize> = (0..group.panels.len())
|
||||
.filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i]))
|
||||
.collect();
|
||||
let weights: Vec<f32> = open.iter().map(|i| group.weights[*i]).collect();
|
||||
let spans = crate::ui::dock::slot_spans(&weights, avail);
|
||||
let at = open
|
||||
.iter()
|
||||
.position(|i| group.panels[*i] == id)
|
||||
.expect("dropped panel");
|
||||
let (top, bottom) = spans[at];
|
||||
let w = group
|
||||
.panels
|
||||
.iter()
|
||||
.map(|p| after.width(*p, self.win_size.0))
|
||||
.fold(0.0, f32::max);
|
||||
// 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),
|
||||
@@ -627,6 +586,37 @@ impl OpenCADStudio {
|
||||
.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 open: Vec<usize> = (0..group.panels.len())
|
||||
.filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i]))
|
||||
.collect();
|
||||
let weights: Vec<f32> = open.iter().map(|i| group.weights[*i]).collect();
|
||||
let at = open.iter().position(|i| group.panels[*i] == id)?;
|
||||
let (_, avail) = self.dock_workspace_size();
|
||||
let (top, bottom) = *crate::ui::dock::slot_spans(&weights, avail).get(at)?;
|
||||
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.
|
||||
@@ -767,13 +757,10 @@ fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Messa
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Canvas that draws a label rotated 90° (for a collapsed panel's bar).
|
||||
/// Canvas that draws a floating title bar's label, reading upward from the
|
||||
/// bar's bottom end.
|
||||
struct VBarLabel {
|
||||
text: String,
|
||||
clockwise: bool,
|
||||
/// Start the text at the reading-start end of the bar instead of
|
||||
/// centring it.
|
||||
from_start: bool,
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for VBarLabel {
|
||||
@@ -790,27 +777,15 @@ impl canvas::Program<Message> for VBarLabel {
|
||||
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
|
||||
}));
|
||||
// After the rotation the text runs along local +x, so the
|
||||
// reading start sits at -height/2.
|
||||
let (position, align_x) = if self.from_start {
|
||||
(
|
||||
iced::Point::new(-bounds.height / 2.0, 0.0),
|
||||
iced::advanced::text::Alignment::Left,
|
||||
)
|
||||
} else {
|
||||
(iced::Point::ORIGIN, iced::advanced::text::Alignment::Center)
|
||||
};
|
||||
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,
|
||||
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,
|
||||
align_x: iced::advanced::text::Alignment::Left,
|
||||
align_y: iced::alignment::Vertical::Center,
|
||||
shaping: iced::advanced::text::Shaping::Advanced,
|
||||
..Default::default()
|
||||
@@ -840,9 +815,7 @@ fn floating_title_bar(
|
||||
let close = crate::ui::dock::close_button(id, tip);
|
||||
let pin = crate::ui::dock::pin_button(id, auto_collapse, tip);
|
||||
let title = canvas(VBarLabel {
|
||||
text: id.title().to_string(),
|
||||
clockwise: false,
|
||||
from_start: true,
|
||||
text: t!(id.title()).into_owned(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
@@ -875,8 +848,8 @@ fn floating_title_bar(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Grabbable separator for a docked panel managed by the general dock. Same
|
||||
/// visual as the previous per-panel divider but emits generic dock messages.
|
||||
/// 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))
|
||||
@@ -900,13 +873,13 @@ const DOCK_MENU_W: f32 = 230.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. `shown` holds each pallet with that state.
|
||||
/// open on this edge. `checked` holds each pallet with that state.
|
||||
fn dock_edge_menu(
|
||||
side: crate::app::config::DockSide,
|
||||
shown: &[(crate::ui::dock::PanelId, bool)],
|
||||
checked: &[(crate::ui::dock::PanelId, bool)],
|
||||
) -> Element<'static, Message> {
|
||||
use crate::ui::dock::DockMsg;
|
||||
let rows: Vec<Element<'static, Message>> = shown
|
||||
let rows: Vec<Element<'static, Message>> = checked
|
||||
.iter()
|
||||
.map(|(id, checked)| {
|
||||
let id = *id;
|
||||
@@ -918,7 +891,7 @@ fn dock_edge_menu(
|
||||
button(
|
||||
row![
|
||||
crate::ui::icons::themed(id.icon(), 16.0),
|
||||
text(id.title()).size(12).width(Fill),
|
||||
text(t!(id.title())).size(12).width(Fill),
|
||||
check,
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -991,7 +964,7 @@ fn dock_splitter(
|
||||
/// its name as the header.
|
||||
fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> {
|
||||
let header = container(
|
||||
text(id.title())
|
||||
text(t!(id.title()))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None)
|
||||
.ellipsis(iced::advanced::text::Ellipsis::End),
|
||||
|
||||
@@ -173,11 +173,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.is_placed(id) {
|
||||
self.dock
|
||||
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_reveal(id);
|
||||
self.dock_open_at_default(id);
|
||||
return self.graph_run(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2580,12 +2580,7 @@ impl OpenCADStudio {
|
||||
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.is_placed(PanelId::ExternalReferences) {
|
||||
self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX);
|
||||
}
|
||||
self.dock_reveal(PanelId::ExternalReferences);
|
||||
self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences);
|
||||
self.refresh_xref_manager();
|
||||
}
|
||||
Task::none()
|
||||
|
||||
+78
-42
@@ -26,10 +26,12 @@ pub enum DockMsg {
|
||||
AutoCollapseToggle(PanelId),
|
||||
/// Close / hide `panel`.
|
||||
Close(PanelId),
|
||||
/// The pointer entered (`Some`) or left (`None`) a pallet's strip icon or
|
||||
/// floating frame: highlights the icon and, where the pallet auto-hides,
|
||||
/// reveals it.
|
||||
Hover(Option<PanelId>),
|
||||
/// 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.
|
||||
@@ -45,8 +47,10 @@ pub enum DockMsg {
|
||||
/// 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 (`Some`) or left (`None`) a docked title bar.
|
||||
TitleHover(Option<PanelId>),
|
||||
/// 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<DockSide>),
|
||||
@@ -55,9 +59,10 @@ pub enum DockMsg {
|
||||
EdgeMenuToggle(DockSide, PanelId),
|
||||
/// Begin dragging a whole group by its edge band in the icon strip.
|
||||
GroupGrab(DockSide, usize),
|
||||
/// The pointer entered (`Some`) or left (`None`) a group's edge band.
|
||||
GripHover(Option<(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: dock the panel on that side.
|
||||
@@ -305,6 +310,18 @@ pub struct FloatPanel {
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl FloatPanel {
|
||||
/// 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.
|
||||
@@ -407,11 +424,13 @@ impl DockState {
|
||||
let groups = self.groups_mut(side);
|
||||
for g in groups.iter_mut() {
|
||||
g.heal();
|
||||
let keep: Vec<bool> = g.panels.iter().map(|id| seen.insert(*id)).collect();
|
||||
let mut k = keep.iter();
|
||||
g.weights.retain(|_| *k.next().expect("parallel"));
|
||||
let mut k = keep.iter();
|
||||
g.panels.retain(|_| *k.next().expect("parallel"));
|
||||
(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();
|
||||
@@ -419,6 +438,7 @@ impl DockState {
|
||||
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 docked: its side and group index.
|
||||
@@ -451,9 +471,7 @@ 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
|
||||
@@ -509,9 +527,12 @@ impl DockState {
|
||||
}
|
||||
|
||||
/// Group `group`'s docked width: its own once sized, else the widest of
|
||||
/// its pallets' own widths.
|
||||
/// 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 g = &self.groups(side)[group];
|
||||
let Some(g) = self.groups(side).get(group) else {
|
||||
return DOCK_MIN_W;
|
||||
};
|
||||
if g.width > 0.0 {
|
||||
return g.width;
|
||||
}
|
||||
@@ -523,9 +544,10 @@ impl DockState {
|
||||
|
||||
/// 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)[group]
|
||||
.panels
|
||||
.iter()
|
||||
self.groups(side)
|
||||
.get(group)
|
||||
.into_iter()
|
||||
.flat_map(|g| g.panels.iter())
|
||||
.map(|id| id.max_width())
|
||||
.fold(DOCK_MIN_W, f32::max)
|
||||
}
|
||||
@@ -533,8 +555,11 @@ impl DockState {
|
||||
/// 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 {
|
||||
let max = self.group_max_width(side, group).min(win_w * 0.45).max(DOCK_MIN_W);
|
||||
self.group_width(side, group).clamp(DOCK_MIN_W, max)
|
||||
on_screen_width(
|
||||
self.group_width(side, group),
|
||||
self.group_max_width(side, group),
|
||||
win_w,
|
||||
)
|
||||
}
|
||||
|
||||
/// Size group `group` on `side`, clamped.
|
||||
@@ -547,9 +572,11 @@ impl DockState {
|
||||
|
||||
/// 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)[group]
|
||||
.panels
|
||||
.iter()
|
||||
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);
|
||||
@@ -566,12 +593,17 @@ impl DockState {
|
||||
return Some((side, gi, pos, weight, false));
|
||||
}
|
||||
self.groups_mut(side).remove(gi);
|
||||
// Keep showing the same group, or its neighbour when it went away.
|
||||
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);
|
||||
}
|
||||
Some((side, gi, pos, weight, true))
|
||||
}
|
||||
|
||||
/// Dock `id` as a new group of its own on `side` at insertion `index`
|
||||
@@ -640,12 +672,10 @@ impl DockState {
|
||||
index -= 1;
|
||||
}
|
||||
}
|
||||
let shown_here = self.shown(side);
|
||||
let g = self.groups_mut(side).remove(group);
|
||||
if side != to && shown_here > group {
|
||||
self.show_group(side, shown_here - 1);
|
||||
} else if side != to && shown_here >= self.groups(side).len() && shown_here > 0 {
|
||||
self.show_group(side, shown_here - 1);
|
||||
// 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());
|
||||
@@ -814,6 +844,12 @@ 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.
|
||||
@@ -889,8 +925,8 @@ pub fn strip_layout(groups: &[(usize, usize)]) -> (Vec<StripGroup>, f32) {
|
||||
pub enum StripHit {
|
||||
/// A new group before group `group`; `None` = after the last group.
|
||||
NewGroup(Option<usize>),
|
||||
/// Group `group`, at position `index` among its open pallets.
|
||||
Tab { group: usize, index: usize },
|
||||
/// 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
|
||||
@@ -904,7 +940,7 @@ pub fn strip_hit(y: f32, layout: &[StripGroup]) -> StripHit {
|
||||
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::Tab {
|
||||
return StripHit::Join {
|
||||
group: g.group,
|
||||
index,
|
||||
};
|
||||
@@ -961,8 +997,8 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a,
|
||||
)
|
||||
.on_press(Message::Dock(DockMsg::DockGrab(id)))
|
||||
.on_double_click(Message::Dock(DockMsg::FloatOut(id)))
|
||||
.on_enter(Message::Dock(DockMsg::TitleHover(Some(id))))
|
||||
.on_exit(Message::Dock(DockMsg::TitleHover(None)))
|
||||
.on_enter(Message::Dock(DockMsg::TitleHover(id)))
|
||||
.on_exit(Message::Dock(DockMsg::TitleHoverEnd(id)))
|
||||
.interaction(iced::mouse::Interaction::Grab)
|
||||
.into()
|
||||
}
|
||||
@@ -1355,17 +1391,17 @@ mod tests {
|
||||
// Top half of the first icon: into group 0 at the top.
|
||||
assert_eq!(
|
||||
strip_hit(g0.icons_top + 10.0, &layout),
|
||||
StripHit::Tab { group: 0, index: 0 }
|
||||
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::Tab { group: 0, index: 1 }
|
||||
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::Tab { group: 0, index: 3 }
|
||||
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)));
|
||||
|
||||
Reference in new issue
Block a user