mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 09:33:57 +00:00
UI.8: preserve application docking chrome and group gestures
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
||||
embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and
|
||||
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
|
||||
`docking` supplies transactional panel moves and an optional docking surface.
|
||||
Adapters cover all twelve Rust Craft apps; the [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
|
||||
The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
|
||||
records their scope and acceptance limits. Assets, complete themes, localization and command handling
|
||||
remain in each application. No icons, fonts or application engines are bundled.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ mod surface;
|
||||
mod targets;
|
||||
|
||||
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
|
||||
pub use surface::{DockArea, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag};
|
||||
pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag};
|
||||
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -68,11 +68,15 @@ pub struct DockOutput<P> {
|
||||
pub actions: Vec<Action<P>>,
|
||||
pub error: Option<DockError>,
|
||||
pub focused: Option<P>,
|
||||
/// Whole-floating-group gesture identity; host may display its own group drop preview.
|
||||
pub group_drag: Option<P>,
|
||||
/// Whole-group release for host-owned atomic docking policy.
|
||||
pub group_drop: Option<(P, Pos2)>,
|
||||
}
|
||||
|
||||
impl<P> Default for DockOutput<P> {
|
||||
fn default() -> Self {
|
||||
Self { actions: Vec::new(), error: None, focused: None }
|
||||
Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +115,12 @@ struct Drag<P> {
|
||||
grab: Vec2,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct GroupDrag<P> {
|
||||
panel: P,
|
||||
original: [f32; 4],
|
||||
}
|
||||
|
||||
/// Start a docking drag from an application's existing tab/header. Use the same area ID when
|
||||
/// switching from a legacy layout to [`DockArea`]. The model is untouched until a valid release.
|
||||
pub fn begin_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id, panel: P, source_rect: Rect) -> bool {
|
||||
@@ -127,21 +137,57 @@ pub fn cancel_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id)
|
||||
ctx.data_mut(|d| d.remove::<Drag<P>>(area_id.with("drag")));
|
||||
}
|
||||
|
||||
/// Optional renderer for applications without an existing docking surface. Apps with custom
|
||||
/// chrome can instead use the model, target resolver and resize/tab primitives independently.
|
||||
pub struct DockArea {
|
||||
id: Id,
|
||||
/// One mutable application adapter owns bodies, menus and shaped text, avoiding competing
|
||||
/// mutable closures. Logical labels remain separate and are used for tab accessibility.
|
||||
pub trait DockContent<P> {
|
||||
fn body(&mut self, ui: &mut Ui, panel: &P);
|
||||
fn label(&mut self, _ui: &Ui, _panel: &P, logical: &str, _font: &FontId, _color: Color32) -> egui::WidgetText {
|
||||
logical.into()
|
||||
}
|
||||
fn panel_menu(&mut self, _ui: &mut Ui, _panel: &P) {}
|
||||
fn group_header_width(&self, _panels: &[P], _active: &P, _floating: bool) -> f32 {
|
||||
0.0
|
||||
}
|
||||
fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {}
|
||||
}
|
||||
|
||||
impl DockArea {
|
||||
struct BodyContent<F>(F);
|
||||
impl<P, F: FnMut(&mut Ui, &P)> DockContent<P> for BodyContent<F> {
|
||||
fn body(&mut self, ui: &mut Ui, panel: &P) {
|
||||
(self.0)(ui, panel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional renderer for applications without an existing docking surface. Apps with custom
|
||||
/// chrome can instead use the model, target resolver and resize/tab primitives independently.
|
||||
pub struct DockArea<P = ()> {
|
||||
id: Id,
|
||||
activation: Option<P>,
|
||||
blockers: Vec<Rect>,
|
||||
}
|
||||
|
||||
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
|
||||
pub fn new(id: Id) -> Self {
|
||||
Self { id }
|
||||
Self { id, activation: None, blockers: Vec::new() }
|
||||
}
|
||||
|
||||
/// Raise and focus a panel's owning floating area on this frame. Apply Activate through
|
||||
/// the application's command path as well to select its tab.
|
||||
pub fn raise_panel(mut self, panel: P) -> Self {
|
||||
self.activation = Some(panel);
|
||||
self
|
||||
}
|
||||
|
||||
/// Host overlays that must reject docking gestures, including floating fallback.
|
||||
pub fn blocked_rects(mut self, rects: &[Rect]) -> Self {
|
||||
self.blockers = rects.to_vec();
|
||||
self
|
||||
}
|
||||
|
||||
/// Render a validated snapshot and return requested actions for the app's command handler.
|
||||
/// No action is applied here. Panel contents get a global stable egui ID across moves.
|
||||
/// Floating groups are egui areas inside this viewport; this does not create native windows.
|
||||
pub fn show<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(
|
||||
pub fn show(
|
||||
self,
|
||||
ui: &mut Ui,
|
||||
layout: &Layout<P>,
|
||||
@@ -155,7 +201,7 @@ impl DockArea {
|
||||
|
||||
/// Like [`Self::show`], with per-panel size policies supplied by the application.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn show_with_limits<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(
|
||||
pub fn show_with_limits(
|
||||
self,
|
||||
ui: &mut Ui,
|
||||
layout: &Layout<P>,
|
||||
@@ -163,7 +209,23 @@ impl DockArea {
|
||||
label: impl Fn(&P) -> String,
|
||||
permissions: impl Fn(&P) -> Permissions,
|
||||
limits: impl Fn(&P) -> PanelLimits,
|
||||
mut body: impl FnMut(&mut Ui, &P),
|
||||
body: impl FnMut(&mut Ui, &P),
|
||||
) -> DockOutput<P> {
|
||||
self.show_customized(ui, layout, style, label, permissions, limits, &mut BodyContent(body))
|
||||
}
|
||||
|
||||
/// Render with application-owned chrome and shaped labels. Header callbacks queue app
|
||||
/// commands; structural changes are applied after rendering, like DockOutput actions.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn show_customized(
|
||||
self,
|
||||
ui: &mut Ui,
|
||||
layout: &Layout<P>,
|
||||
style: &DockStyle,
|
||||
label: impl Fn(&P) -> String,
|
||||
permissions: impl Fn(&P) -> Permissions,
|
||||
limits: impl Fn(&P) -> PanelLimits,
|
||||
content: &mut impl DockContent<P>,
|
||||
) -> DockOutput<P> {
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
if let Err(error) = layout.validate() {
|
||||
@@ -180,7 +242,9 @@ impl DockArea {
|
||||
label: &label,
|
||||
permissions: &permissions,
|
||||
limits: &limits,
|
||||
body: &mut body,
|
||||
content,
|
||||
activation: self.activation,
|
||||
blockers: self.blockers,
|
||||
layout,
|
||||
targets: Vec::new(),
|
||||
output: DockOutput::default(),
|
||||
@@ -190,6 +254,7 @@ impl DockArea {
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
}
|
||||
renderer.floating(ui.ctx());
|
||||
renderer.finish_group_drag(ui.ctx());
|
||||
renderer.finish_drag(ui.ctx());
|
||||
renderer.output
|
||||
}
|
||||
@@ -201,7 +266,9 @@ struct Renderer<'a, P> {
|
||||
label: &'a dyn Fn(&P) -> String,
|
||||
permissions: &'a dyn Fn(&P) -> Permissions,
|
||||
limits: &'a dyn Fn(&P) -> PanelLimits,
|
||||
body: &'a mut dyn FnMut(&mut Ui, &P),
|
||||
content: &'a mut dyn DockContent<P>,
|
||||
activation: Option<P>,
|
||||
blockers: Vec<Rect>,
|
||||
layout: &'a Layout<P>,
|
||||
targets: Vec<DropTarget<P>>,
|
||||
output: DockOutput<P>,
|
||||
@@ -381,7 +448,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
let mut child =
|
||||
ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
(self.body)(&mut child, panel);
|
||||
self.content.body(&mut child, panel);
|
||||
if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) {
|
||||
self.output.focused = Some(panel.clone());
|
||||
}
|
||||
@@ -403,11 +470,58 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
|
||||
let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
|
||||
ui.painter().rect_filled(strip, 0.0, self.style.tab_background);
|
||||
let widths: Vec<f32> =
|
||||
panels.iter().map(|p| ui.painter().layout_no_wrap((self.label)(p), self.style.font.clone(), self.style.text).size().x + 24.0).collect();
|
||||
let fit = tabs::fit(&widths, active, strip.width(), 40.0, 22.0);
|
||||
if floating {
|
||||
let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom()));
|
||||
let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label));
|
||||
if panels.iter().all(|p| (self.permissions)(p).movable)
|
||||
&& response.drag_started()
|
||||
&& let Some(first) = panels.first()
|
||||
{
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(
|
||||
self.id.with("group-drag"),
|
||||
GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] },
|
||||
)
|
||||
});
|
||||
}
|
||||
if panels.iter().all(|p| (self.permissions)(p).movable)
|
||||
&& response.dragged()
|
||||
&& ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))).is_some()
|
||||
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
|
||||
{
|
||||
let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect());
|
||||
if let Some(first) = panels.first() {
|
||||
self.output
|
||||
.actions
|
||||
.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] });
|
||||
}
|
||||
}
|
||||
ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border);
|
||||
strip.min.x = grip.right();
|
||||
}
|
||||
let requested = self.content.group_header_width(panels, selected, floating);
|
||||
let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 };
|
||||
if header_width > 0.0 {
|
||||
let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max);
|
||||
let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header));
|
||||
child.set_clip_rect(header.intersect(ui.clip_rect()));
|
||||
self.content.group_header(&mut child, panels, selected, floating);
|
||||
}
|
||||
let widths: Vec<f32> = panels
|
||||
.iter()
|
||||
.map(|p| {
|
||||
self.content
|
||||
.label(ui, p, &(self.label)(p), &self.style.font, self.style.text)
|
||||
.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone())
|
||||
.size()
|
||||
.x
|
||||
+ 24.0
|
||||
})
|
||||
.collect();
|
||||
let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0);
|
||||
let mut x = strip.left();
|
||||
let mut targets = Vec::new();
|
||||
for (index, width) in &fit.shown {
|
||||
@@ -426,7 +540,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for index in &fit.overflow {
|
||||
if let Some(panel) = panels.get(*index)
|
||||
&& ui.button((self.label)(panel)).clicked()
|
||||
&& self.menu_label(ui, panel).clicked()
|
||||
{
|
||||
self.output.actions.push(Action::Activate { panel: panel.clone() });
|
||||
self.output.focused = Some(panel.clone());
|
||||
@@ -454,22 +568,31 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
|
||||
}
|
||||
|
||||
fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response {
|
||||
let logical = (self.label)(panel);
|
||||
let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text);
|
||||
let response = ui.button(text);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical));
|
||||
response
|
||||
}
|
||||
|
||||
fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) {
|
||||
let policy = (self.permissions)(panel);
|
||||
let label = (self.label)(panel);
|
||||
let color = if active { self.style.text } else { self.style.inactive_text };
|
||||
let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley(
|
||||
ui,
|
||||
Some(egui::TextWrapMode::Extend),
|
||||
f32::INFINITY,
|
||||
self.style.font.clone(),
|
||||
);
|
||||
let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active)
|
||||
.sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() })
|
||||
.focus_stroke(Stroke::new(1.0, self.style.accent))
|
||||
.show_at(ui, rect, |ui, response| {
|
||||
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background });
|
||||
painter.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&label,
|
||||
self.style.font.clone(),
|
||||
if active { self.style.text } else { self.style.inactive_text },
|
||||
);
|
||||
painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color);
|
||||
});
|
||||
if response.clicked() {
|
||||
self.output.actions.push(Action::Activate { panel: panel.clone() });
|
||||
@@ -479,6 +602,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
begin_drag(ui.ctx(), self.id, panel.clone(), group);
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
self.content.panel_menu(ui, panel);
|
||||
if policy.movable && policy.floatable && ui.button(&self.style.float_label).clicked() {
|
||||
let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect());
|
||||
self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] });
|
||||
@@ -488,7 +612,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
ui.menu_button(&self.style.move_label, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for target in self.layout.panels() {
|
||||
if target != panel && (self.permissions)(target).accepts_tabs && ui.button((self.label)(target)).clicked() {
|
||||
if target != panel && (self.permissions)(target).accepts_tabs && self.menu_label(ui, target).clicked() {
|
||||
self.output.actions.push(Action::Move {
|
||||
panel: panel.clone(),
|
||||
anchor: target.clone(),
|
||||
@@ -525,6 +649,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
let size = vec2(width, height).max(limits.min).min(limits.max);
|
||||
let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen);
|
||||
let id = self.id.with(("floating", first));
|
||||
if self.activation.as_ref().is_some_and(|panel| group.panels.contains(panel)) {
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
|
||||
ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first)))));
|
||||
self.output.focused = self.activation.clone();
|
||||
}
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| {
|
||||
ui.set_min_size(rect.size());
|
||||
let rect = Rect::from_min_size(ui.min_rect().min, rect.size());
|
||||
@@ -570,6 +699,29 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_group_drag(&mut self, ctx: &Context) {
|
||||
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))) else { return };
|
||||
if !self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel) && g.panels.iter().all(|p| (self.permissions)(p).movable)) {
|
||||
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(self.id.with("group-drag")));
|
||||
return;
|
||||
}
|
||||
let (escape, focused, down, released, pointer) =
|
||||
ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos()));
|
||||
if escape || !focused || !down {
|
||||
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(self.id.with("group-drag")));
|
||||
if escape || !focused {
|
||||
self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original });
|
||||
} else if released
|
||||
&& let Some(pointer) = pointer.filter(|p| p.is_finite())
|
||||
&& !self.blockers.iter().any(|r| r.contains(pointer))
|
||||
{
|
||||
self.output.group_drop = Some((drag.panel, pointer));
|
||||
}
|
||||
} else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) {
|
||||
self.output.group_drag = Some(drag.panel);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_drag(&mut self, ctx: &Context) {
|
||||
let Some(drag) = ctx.data(|d| d.get_temp::<Drag<P>>(self.id.with("drag"))) else { return };
|
||||
let (pointer, down, released, focused) =
|
||||
@@ -585,6 +737,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
|
||||
}
|
||||
return;
|
||||
};
|
||||
if self.blockers.iter().any(|rect| rect.contains(pointer)) {
|
||||
if released {
|
||||
cancel_drag::<P>(ctx, self.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let proposal = resolve_drop(&drag.panel, pointer, &self.targets);
|
||||
if released {
|
||||
cancel_drag::<P>(ctx, self.id);
|
||||
|
||||
@@ -613,3 +613,213 @@ fn floating_only_surface_does_not_allocate_the_callers_document_region() {
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() {
|
||||
let ctx = Context::default();
|
||||
let mut layout = sample();
|
||||
let original = layout.floating[0].clone();
|
||||
for _ in 0..2 {
|
||||
step(&ctx, &mut layout, vec![]);
|
||||
}
|
||||
let start = pos2(86.0, 82.0);
|
||||
step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]);
|
||||
let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]);
|
||||
assert!(actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })));
|
||||
assert_eq!(layout.floating[0].panels, original.panels);
|
||||
assert_eq!(layout.floating[0].active, original.active);
|
||||
assert_ne!(layout.floating[0].rect, original.rect);
|
||||
step(&ctx, &mut layout, vec![pointer_button(start + vec2(50.0, 40.0), false)]);
|
||||
for _ in 0..2 {
|
||||
step(&ctx, &mut layout, vec![]);
|
||||
}
|
||||
let tab = ctx.read_response(Id::new("test-area").with(("tab", &5u32))).unwrap().rect.center();
|
||||
step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]);
|
||||
step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(8.0, 0.0))]);
|
||||
step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(100.0, 100.0))]);
|
||||
let detached = step(&ctx, &mut layout, vec![pointer_button(tab + vec2(100.0, 100.0), false)]);
|
||||
assert!(detached.iter().any(|a| matches!(a, Action::Float { panel: 5, .. })));
|
||||
assert_eq!(layout.floating.len(), 2);
|
||||
assert_eq!(layout.floating[0].panels, vec![6]);
|
||||
}
|
||||
|
||||
struct CustomContent {
|
||||
labels: Vec<String>,
|
||||
headers: usize,
|
||||
menus: usize,
|
||||
bodies: usize,
|
||||
}
|
||||
impl DockContent<u32> for CustomContent {
|
||||
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
self.bodies += 1;
|
||||
ui.label("Editable content");
|
||||
}
|
||||
fn label(&mut self, ui: &egui::Ui, _: &u32, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText {
|
||||
self.labels.push(logical.to_owned());
|
||||
ui.painter().layout_no_wrap("Custom visual label".into(), font.clone(), color).into()
|
||||
}
|
||||
fn panel_menu(&mut self, ui: &mut egui::Ui, _: &u32) {
|
||||
self.menus += 1;
|
||||
ui.button("App menu").clicked();
|
||||
}
|
||||
fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 {
|
||||
40.0
|
||||
}
|
||||
fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) {
|
||||
self.headers += 1;
|
||||
assert!(ui.max_rect().width() <= 40.0);
|
||||
ui.label("⋮");
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_frame(
|
||||
ctx: &Context,
|
||||
layout: &Layout<u32>,
|
||||
content: &mut CustomContent,
|
||||
events: Vec<Event>,
|
||||
blockers: &[Rect],
|
||||
raise: bool,
|
||||
) -> DockOutput<u32> {
|
||||
let mut out = DockOutput::default();
|
||||
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| {
|
||||
let style = DockStyle::from_ui(ui);
|
||||
let mut area = DockArea::new(Id::new("test-area")).blocked_rects(blockers);
|
||||
if raise {
|
||||
area = area.raise_panel(5);
|
||||
}
|
||||
out = area.show_customized(ui, layout, &style, |p| format!("Logical {p}"), |_| Permissions::default(), |_| PanelLimits::default(), content);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() {
|
||||
let ctx = Context::default();
|
||||
let layout = sample();
|
||||
let mut content = CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 };
|
||||
for _ in 0..2 {
|
||||
custom_frame(&ctx, &layout, &mut content, vec![], &[], false);
|
||||
}
|
||||
let out = custom_frame(&ctx, &layout, &mut content, vec![], &[], true);
|
||||
assert_eq!(out.focused, Some(5));
|
||||
assert!(out.actions.is_empty());
|
||||
assert!(content.headers >= 3 && content.bodies >= 3);
|
||||
assert!(content.labels.iter().any(|s| s == "Logical 5"));
|
||||
let at = pos2(130.0, 82.0);
|
||||
custom_frame(
|
||||
&ctx,
|
||||
&layout,
|
||||
&mut content,
|
||||
vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }],
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
custom_frame(
|
||||
&ctx,
|
||||
&layout,
|
||||
&mut content,
|
||||
vec![Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }],
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
assert!(content.menus > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_appearing_mid_drag_blocks_release_and_float_fallback() {
|
||||
let ctx = Context::default();
|
||||
let layout = sample();
|
||||
let mut content = CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 };
|
||||
let source = Rect::from_min_size(pos2(80.0, 70.0), vec2(300.0, 220.0));
|
||||
custom_frame(&ctx, &layout, &mut content, vec![Event::PointerMoved(pos2(130.0, 82.0)), pointer_button(pos2(130.0, 82.0), true)], &[], false);
|
||||
assert!(begin_drag(&ctx, Id::new("test-area"), 5, source));
|
||||
let at = pos2(700.0, 400.0);
|
||||
let blocker = Rect::from_min_size(pos2(650.0, 350.0), vec2(100.0, 100.0));
|
||||
let out = custom_frame(&ctx, &layout, &mut content, vec![Event::PointerMoved(at), pointer_button(at, false)], &[blocker], false);
|
||||
assert!(out.actions.is_empty());
|
||||
let out = custom_frame(&ctx, &layout, &mut content, vec![], &[], false);
|
||||
assert!(out.actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_move_escape_restores_original_geometry() {
|
||||
let ctx = Context::default();
|
||||
let mut layout = sample();
|
||||
let original = layout.floating[0].rect;
|
||||
for _ in 0..2 {
|
||||
step(&ctx, &mut layout, vec![]);
|
||||
}
|
||||
let start = pos2(86.0, 82.0);
|
||||
step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]);
|
||||
step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]);
|
||||
assert_ne!(layout.floating[0].rect, original);
|
||||
step(
|
||||
&ctx,
|
||||
&mut layout,
|
||||
vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
|
||||
);
|
||||
assert_eq!(layout.floating[0].rect, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_headers_and_labels_render_at_small_and_large_scales() {
|
||||
for scale in [0.75, 1.0, 1.5, 2.0] {
|
||||
let ctx = Context::default();
|
||||
ctx.set_pixels_per_point(scale);
|
||||
let mut content = CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 };
|
||||
let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 5)), floating: vec![] };
|
||||
let mut out = DockOutput::default();
|
||||
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0 * scale, 160.0))), ..Default::default() }, |ui| {
|
||||
let style = DockStyle::from_ui(ui);
|
||||
out = DockArea::new(Id::new("scaled")).show_customized(
|
||||
ui,
|
||||
&layout,
|
||||
&style,
|
||||
|p| format!("Logical {p}"),
|
||||
|_| Permissions::default(),
|
||||
|_| PanelLimits::default(),
|
||||
&mut content,
|
||||
);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(out.error.is_none());
|
||||
assert!(content.headers > 0 && content.bodies > 0);
|
||||
assert!(content.labels.iter().any(|s| s == "Logical 6"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_group_member_blocks_whole_group_gesture_and_host_drop() {
|
||||
let ctx = Context::default();
|
||||
let layout = sample();
|
||||
let start = pos2(86.0, 82.0);
|
||||
for events in [
|
||||
vec![],
|
||||
vec![],
|
||||
vec![Event::PointerMoved(start), pointer_button(start, true)],
|
||||
vec![Event::PointerMoved(pos2(300.0, 300.0))],
|
||||
vec![pointer_button(pos2(300.0, 300.0), false)],
|
||||
] {
|
||||
let mut out = DockOutput::default();
|
||||
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| {
|
||||
let style = DockStyle::from_ui(ui);
|
||||
out = DockArea::new(Id::new("test-area")).show(
|
||||
ui,
|
||||
&layout,
|
||||
&style,
|
||||
|p| format!("Panel {p}"),
|
||||
|p| if *p == 6 { Permissions::PROTECTED } else { Permissions::default() },
|
||||
|ui, _| {
|
||||
ui.label("Body");
|
||||
},
|
||||
);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(out.group_drag.is_none() && out.group_drop.is_none());
|
||||
assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,3 +109,31 @@ CRAFT_UI_DOCK_VISUAL_DIR=/absolute/output/path cargo test -p craft-ui --test doc
|
||||
|
||||
These are offscreen egui checks. An adopting application still needs command, content, workspace
|
||||
reload, theme and input tests using its own renderer and persistence adapter.
|
||||
|
||||
## Application chrome and activation
|
||||
|
||||
`DockArea::show_customized` accepts one mutable `DockContent<P>` adapter, with body,
|
||||
panel-menu, group-header and label methods. Apps queue commands in these callbacks and
|
||||
apply them after rendering the readonly snapshot. `group_header_width` reserves a bounded
|
||||
trailing portion of the existing tab strip; its child UI is clipped to that region.
|
||||
Collapsed accordion entries still have panel menus; collapsing tab groups is host policy.
|
||||
|
||||
`label` returns `WidgetText`, including an application-shaped galley, used for tab width,
|
||||
painting and menu labels. The separate logical label callback supplies tab accessibility
|
||||
text. Original `show` and `show_with_limits` call sites remain supported.
|
||||
|
||||
Apply `Action::Activate` in the host, then use `DockArea::raise_panel(panel)` on the next
|
||||
frame to raise the owning in-viewport floating layer and focus the panel tab. This request
|
||||
does not mutate the serialized layout or persist z-order. Floating area identity still
|
||||
derives from the first panel; native-window identity remains host-owned.
|
||||
|
||||
Floating groups have a distinct 12-point grip in the existing tab strip. Moving it emits
|
||||
`MoveFloating` for the whole group; dragging an individual tab detaches just that panel.
|
||||
`DockOutput::group_drag` and `group_drop` expose the group identity and release position
|
||||
for app-owned atomic group docking policies. Escape/lost focus restores the group's
|
||||
starting rectangle. No generic group docking or app command registry is inferred.
|
||||
|
||||
Hosts supply overlay rectangles via `blocked_rects` before showing the surface. These
|
||||
block drop previews, release actions and float fallback at the pointer, including overlays
|
||||
appearing mid-drag. Hosts must provide current overlay geometry or cancel docking when
|
||||
modal state prevents interaction; later-drawn overlays cannot be discovered retroactively.
|
||||
|
||||
Reference in New Issue
Block a user