Window: drag a document tab out to float it, and a window back to dock it

A document tab dragged more than 24 pt above or below the tab strip
floats its document in a window under the pointer and makes it the
current document (window.arrange.floatInWindow takes index and at for
this). A shorter drag, or one along the strip, leaves the tabs as they
are. A document window dragged by its title bar onto the tab strip goes
back under the tabs; the strip shows an outline while a window is over
it. A desktop document window is an OS window, so it has the document's
tab at its top: dragging that tab moves the window, and dropping it on
the main window's tab strip docks it (window.arrange.consolidateAllWindows
takes index for one document).

A document floated again opens where it is asked to, not where its last
window was.
This commit is contained in:
dotCooCoo
2026-11-03 16:03:39 +07:00
committed by bflatastic
parent 2c0ef0f24e
commit ca8aba6d6a
10 changed files with 443 additions and 32 deletions
+1
View File
@@ -433,6 +433,7 @@ impl VectorcraftApp {
pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) {
let t = theme::Tokens::get(ui.ctx());
ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard);
self.canvas.windows.strip = None;
if self.ui.screen_mode < 3 {
chrome::doc_tabs(self, ui);
}
+1 -1
View File
@@ -30,7 +30,7 @@ use status_bar::{StatusReadoutWidths, status_readouts};
#[cfg(test)]
use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_title};
pub use tabs::{doc_tabs, isolation_bar, step_document};
pub(crate) use tabs::{document_window_title, show_document};
pub(crate) use tabs::{document_window_title, show_document, window_tab};
/// The application bar's height, in points (compact, as in PhotoCraft).
pub const APP_BAR_HEIGHT: f32 = 32.0;
+58 -3
View File
@@ -25,6 +25,51 @@ pub(crate) fn document_window_title(app: &VectorcraftApp, i: usize) -> Option<St
Some(doc_tab_title(app, i, d))
}
/// The height of the tab strip and the size of a tab's title, in points: larger with User
/// Interface › Large Tabs.
fn tab_size(app: &VectorcraftApp) -> (f32, f32) {
if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) }
}
/// The tab at the top of document `i`'s floating OS window ([`crate::doc_windows`]): a strip with
/// the document's tab, as the main window's strip draws it but without its ×. → the tab's
/// response, which senses clicks and drags.
pub(crate) fn window_tab(app: &VectorcraftApp, ui: &mut Ui, i: usize) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (height, title_size) = tab_size(app);
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let title = document_window_title(app, i).unwrap_or_default();
let galley = ui.painter().layout_no_wrap(title, theme::semibold(title_size), t.text_strong);
let r = egui::Rect::from_min_size(strip.min, vec2((galley.size().x + 28.0).min(strip.width()), strip.height() - 1.0));
ui.painter().rect_filled(r, 0.0, t.panel);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.5, t.border));
ui.painter().galley(egui::pos2(r.left() + 14.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.interact(r, ui.scope_id().with("window-tab"), Sense::click_and_drag())
}
/// While document tab `r` is dragged off the tab `strip` far enough to float its document, an
/// outline under the pointer where its window will open; → the window's top-left corner when the
/// drag ends there.
fn tear_off(ui: &Ui, resp: &egui::Response, strip: egui::Rect) -> Option<egui::Pos2> {
use crate::doc_windows::{GRAB, torn_off};
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos().filter(|p| torn_off(strip, *p))
{
let t = Tokens::get(ui.ctx());
let ghost = egui::Rect::from_min_size(p - GRAB, vec2(240.0, 160.0));
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.scope_id().with("tab-tear-off")));
painter.rect_filled(ghost, 0.0, t.accent.gamma_multiply(0.12));
painter.rect_stroke(ghost, 0.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
if !resp.drag_stopped() {
return None;
}
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()))?;
torn_off(strip, p).then_some(p - GRAB)
}
/// Whether a document tab's × comes before its title (macOS) rather than after it (Windows,
/// Linux and the web).
pub(super) const CLOSE_BEFORE_TITLE: bool = cfg!(target_os = "macos");
@@ -44,13 +89,16 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option<usize>, room: f32)
/// taller, with larger titles. Tabs that don't fit are reached from a » button at the strip's
/// right end, which lists every open document, and the active tab is always in view (#746).
/// Documents in floating windows have no tab ([`crate::doc_windows`]); while the current document
/// is in one, the tab of the document the strip shows is the highlighted one.
/// is in one, the tab of the document the strip shows is the highlighted one. A tab dragged more
/// than [`crate::doc_windows::TEAR_OFF`] above or below the strip floats its document in a window
/// under the pointer.
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The width of the » button.
const MORE: f32 = 30.0;
let t = Tokens::get(ui.ctx());
let (height, title_size) = if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) };
let (height, title_size) = tab_size(app);
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
app.canvas.windows.strip = Some(strip);
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
// On the Home screen no tab is the current one.
@@ -74,6 +122,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut x = strip.left();
let mut activate = None;
let mut close = None;
let mut float = None;
let mut hovering_art = false;
for (k, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) {
if x + w > strip.left() + room && Some(k) != active {
@@ -82,7 +131,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(&i) = docked.get(k) else { break };
let is_active = Some(k) == active;
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(*w, strip.height() - 1.0));
let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click_and_drag());
if is_active {
ui.painter().rect_filled(r, 0.0, t.panel);
} else if resp.hovered() {
@@ -101,6 +150,8 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
close = Some(i);
} else if resp.clicked() {
activate = Some(i);
} else if let Some(at) = tear_off(ui, &resp, strip) {
float = Some((i, at));
}
x += w;
}
@@ -131,6 +182,10 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
if let Err(e) = crate::unsaved::close(app, i) {
app.status(e);
}
} else if let Some((i, at)) = float {
if let Err(e) = app.run("window.arrange.floatInWindow", json!({"index": i, "at": [at.x, at.y]})) {
app.status(e);
}
} else if let Some(i) = activate {
show_document(app, i);
}
+161 -14
View File
@@ -5,11 +5,21 @@
//! ([`crate::panel_docking`]). The current document's window holds the canvas
//! ([`crate::canvas::show`]); each other window draws its document as a Tile pane does
//! ([`crate::canvas::pane`]), and a press in it makes its document the current one.
//!
//! A document tab dragged off the tab strip floats its document in a window under the pointer,
//! and a document window dragged onto the tab strip by its title bar (an OS window by the tab at
//! its top) goes back under the tabs.
use serde_json::{Value, json};
use crate::VectorcraftApp;
/// How far above or below the tab strip, in points, a dragged document tab must be released to
/// float its document.
pub(crate) const TEAR_OFF: f32 = 24.0;
/// Where a torn-off document's window opens: this far up and left of the pointer, so the pointer
/// is over its title bar.
pub(crate) const GRAB: egui::Vec2 = egui::vec2(40.0, 12.0);
/// How far apart Cascade sets the windows, down and to the right, in points.
pub(crate) const CASCADE_STEP: f32 = 30.0;
/// How far the first cascaded window is from the document area's top-left corner, in points.
@@ -24,6 +34,9 @@ pub struct DocWindow {
pub uid: u64,
/// Where the window goes, in the main window's points.
pub place: egui::Rect,
/// The window's own number ([`Windows::opened`]): a document floated again gets a new window,
/// which opens where it is asked to, not where its last window was.
number: u32,
/// Counts the moves Cascade gives the window after it opened.
generation: u32,
/// The OS window's position on the screen when it opened or last moved, and the generation of
@@ -36,6 +49,9 @@ pub struct DocWindow {
/// The size of the body its document was last fitted to, and the zoom and center that fit
/// gave: a window resized while its view is still there fits its document again.
fit: Option<Fit>,
/// While its OS window's tab is dragged: where the pointer is from the window's top-left
/// corner, on the screen.
grab: Option<egui::Vec2>,
}
/// [`DocWindow::fit`]: a body size, and a view's zoom and center.
@@ -43,7 +59,7 @@ type Fit = (egui::Vec2, f64, vectorcraft_geom::Point);
impl DocWindow {
fn new(uid: u64, place: egui::Rect) -> Self {
Self { uid, place, generation: 0, screen: None, focused: false, body: None, fit: None }
Self { uid, place, number: 0, generation: 0, screen: None, focused: false, body: None, fit: None, grab: None }
}
}
@@ -57,6 +73,15 @@ pub struct Windows {
current: Option<u64>,
/// While Presentation Mode is on: the floating documents (their uids) it has shown.
presented: Option<Vec<u64>>,
/// The main window's tab strip in its last frame, while it was drawn.
pub(crate) strip: Option<egui::Rect>,
/// Counts the windows opened ([`DocWindow::number`]).
opened: u32,
}
/// Whether a document tab dragged from `strip` to `p` has left it far enough to float.
pub(crate) fn torn_off(strip: egui::Rect, p: egui::Pos2) -> bool {
p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF
}
/// Whether document `i` is in a floating window.
@@ -125,7 +150,10 @@ fn float(app: &mut VectorcraftApp, docs: &[usize]) {
continue;
}
let place = places.get(first + k).copied().unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, MIN_SIZE));
app.ui.floating.push(DocWindow::new(uid, place));
app.canvas.windows.opened = app.canvas.windows.opened.wrapping_add(1);
let mut w = DocWindow::new(uid, place);
w.number = app.canvas.windows.opened;
app.ui.floating.push(w);
if let Some(v) = app.views.get_mut(*i) {
v.fitted = false;
}
@@ -140,15 +168,60 @@ fn floating_indices(app: &VectorcraftApp) -> Vec<usize> {
app.ui.floating.iter().filter_map(|w| app.session.documents().iter().position(|d| d.uid == w.uid)).collect()
}
/// `window.arrange.floatInWindow`: the current document leaves the tab strip for a floating window
/// of its own. A document already floating stays where it is. Returns `{floating}`.
pub(crate) fn float_in_window(app: &mut VectorcraftApp) -> Result<Value, String> {
if let Some(i) = app.session.active_index().filter(|i| !is_floating(app, *i)) {
/// The open document `p`'s `index` names: `Ok(None)` without one.
fn document_param(app: &VectorcraftApp, p: &Value) -> Result<Option<usize>, String> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < app.session.documents().len())
.map(Some)
.ok_or_else(|| "no such document".to_string()),
}
}
/// `window.arrange.floatInWindow`: the current document, or document `index`, leaves the tab strip
/// for a floating window of its own, with its top-left corner at `at` (`[x, y]` in the main
/// window's points) when given, else at the next cascade place. A document already floating stays
/// where it is. Document `index` becomes the current one. Returns `{floating}`.
pub(crate) fn float_in_window(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let index = document_param(app, p)?;
let at = p
.get("at")
.and_then(Value::as_array)
.and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)))
.filter(|at| at.is_finite());
if let Some(i) = index.or(app.session.active_index()).filter(|i| !is_floating(app, *i)) {
float(app, &[i]);
if let (Some(at), Some(w)) = (at, app.ui.floating.last_mut()) {
w.place = egui::Rect::from_min_size(at, w.place.size());
}
}
if let Some(i) = index {
crate::chrome::show_document(app, i);
}
Ok(json!({ "floating": floating_indices(app) }))
}
/// `window.arrange.consolidateAllWindows` with an `index`: document `index`, floating or not,
/// goes back under its tab, fitted there, and becomes the current one; the other windows and Tile
/// stay as they are. Returns `{tiled, floating}`.
pub(crate) fn dock(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let Some(i) = document_param(app, p)? else {
return Err("no such document".into());
};
if is_floating(app, i) {
let uid = app.session.documents().get(i).map(|d| d.uid);
app.ui.floating.retain(|w| Some(w.uid) != uid);
if let Some(v) = app.views.get_mut(i) {
v.fitted = false;
}
}
crate::chrome::show_document(app, i);
Ok(json!({ "tiled": app.ui.tiled, "floating": floating_indices(app) }))
}
/// `window.arrange.floatAllInWindows`: every document under the tab strip goes to a floating window
/// of its own. Returns `{floating}`.
pub(crate) fn float_all(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -216,10 +289,10 @@ fn viewport_id(uid: u64) -> egui::ViewportId {
egui::ViewportId::from_hash_of(("vectorcraft-document-window", uid))
}
/// The id of document window `w` inside the main window: a new one for each Cascade move, so egui
/// opens it at its new place and size.
/// The id of document window `w` inside the main window: a new one for each window and each
/// Cascade move, so egui opens it at its new place and size.
fn window_id(w: &DocWindow) -> egui::Id {
egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation))
egui::Id::unique(("vectorcraft-document-window", w.uid, w.number, w.generation))
}
/// What happened in a document window this frame.
@@ -229,6 +302,10 @@ struct Shown {
close: bool,
/// Its document asks to become the current one.
activate: bool,
/// It is being dragged over the main window's tab strip.
over_strip: bool,
/// It was dropped on the main window's tab strip: its document goes back under the tabs.
dock: bool,
/// Its OS window has the keyboard focus.
focused: bool,
/// Where it drew its document.
@@ -297,6 +374,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let blocked = crate::panel_docking::modal_open(app, ctx);
let mut close = None;
let mut activate = None;
let mut dock = None;
let mut over_strip = false;
for w in app.ui.floating.clone() {
let Some(i) = app.session.documents().iter().position(|d| d.uid == w.uid) else { continue };
let shown = if native {
@@ -316,12 +395,22 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
win.body = shown.body;
win.fit = shown.fit;
}
over_strip |= shown.over_strip;
if shown.close {
close = Some(i);
} else if shown.dock {
dock = Some(i);
} else if shown.activate && app.session.active_index() != Some(i) {
activate = Some(i);
}
}
// The tab strip a dragged window would go back under shows where it lands.
if let Some(strip) = app.canvas.windows.strip.filter(|_| over_strip) {
let t = crate::theme::Tokens::get(ctx);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("vectorcraft-document-dock")));
painter.rect_filled(strip, 0.0, t.accent.gamma_multiply(0.12));
painter.rect_stroke(strip, 0.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
}
if let Some(i) = close {
// The save prompt opens in the main window.
if let Err(e) = crate::unsaved::close(app, i) {
@@ -330,6 +419,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if native && app.ui.dialog.is_some() {
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
}
} else if let Some(i) = dock {
if let Err(e) = app.run("window.arrange.consolidateAllWindows", json!({ "index": i })) {
app.status(e);
}
if native {
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
}
} else if let Some(i) = activate {
crate::chrome::show_document(app, i);
app.canvas.windows.current = app.session.active().map(|d| d.uid);
@@ -367,7 +463,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, (i, blocked): (usize, bool)
}
/// Document `i`'s window `w` as an OS window of its own, `blocked` by a modal or not; `raise`
/// brings it to the front.
/// brings it to the front. The document's tab is at the top of the window, above its body.
fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown {
let vid = viewport_id(w.uid);
let origin = ctx.input(|inp| inp.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(egui::Pos2::ZERO);
@@ -392,6 +488,8 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
let builder =
egui::ViewportBuilder::default().with_title(title).with_position(screen).with_inner_size(w.place.size()).with_min_inner_size(MIN_SIZE);
let mut forwarded = (Vec::new(), None);
// The main window's tab strip on the screen.
let strip = app.canvas.windows.strip.map(|s| s.translate(origin.to_vec2()));
let shown = ctx.show_viewport_immediate(vid, builder, |ui, _| {
let (close, focused) = ui.input(|inp| (inp.viewport().close_requested(), inp.viewport().focused == Some(true)));
if close {
@@ -399,7 +497,10 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
// or a modal can keep the document open.
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
let (activate, rect, fit) = body(app, ui, (i, blocked), w.fit);
let tab = crate::chrome::window_tab(app, ui, i);
let (over_strip, dock) = if blocked { (false, false) } else { drag_tab(app, ui, (w.uid, origin), &tab, strip) };
let (pressed, rect, fit) = body(app, ui, (i, blocked), w.fit);
let activate = pressed || (!blocked && (tab.clicked() || tab.drag_started()));
// Keys the window's widgets left over run the main window's shortcuts and tool keys, as a
// floating panel's do.
if !blocked && !ui.memory(|m| m.focused().is_some()) {
@@ -415,7 +516,7 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
(events, Some(inp.modifiers))
});
}
Shown { close, activate, focused, body: Some(rect), fit }
Shown { close, activate, over_strip, dock, focused, body: Some(rect), fit }
});
if !forwarded.0.is_empty() {
crate::panel_docking::forward_keys(app, ctx, forwarded.0, forwarded.1);
@@ -423,6 +524,45 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
shown
}
/// A drag of document window `uid`'s OS window by the `tab` at its top: the window follows the
/// pointer, and its document goes back under the tabs when the tab is released over the main
/// window's tab `strip` (on the screen). `origin` is the main window's top-left corner on the
/// screen. → whether the pointer is over the strip, and whether the tab was released there.
fn drag_tab(
app: &mut VectorcraftApp,
ui: &egui::Ui,
(uid, origin): (u64, egui::Pos2),
tab: &egui::Response,
strip: Option<egui::Rect>,
) -> (bool, bool) {
let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == uid) else {
return (false, false);
};
if !tab.dragged() && !tab.drag_stopped() {
win.grab = None;
return (false, false);
}
let (inner, outer, pointer) = ui.input(|inp| (inp.viewport().inner_rect, inp.viewport().outer_rect, inp.pointer.latest_pos()));
let (Some(inner), Some(outer), Some(pointer)) = (inner, outer, pointer) else {
return (false, false);
};
let at = inner.min + pointer.to_vec2();
// The pointer stays where it pressed the tab, relative to the window.
let pressed = ui.input(|inp| inp.pointer.press_origin()).map_or(at, |p| inner.min + p.to_vec2());
let grab = *win.grab.get_or_insert(pressed - outer.min);
let over = strip.is_some_and(|s| s.contains(at));
if tab.drag_stopped() {
win.grab = None;
return (over, over);
}
let pos = at - grab;
if pos != outer.min {
win.screen = Some((pos, win.generation));
win.place = egui::Rect::from_min_size(pos - origin.to_vec2(), win.place.size());
}
(over, false)
}
/// Document `i`'s window `w` as a window inside the main window, `blocked` by a modal or not;
/// `raise` brings it to the front.
fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown {
@@ -449,8 +589,15 @@ fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i,
});
// A press on its title bar or edges, which brings the window to the front, also makes its
// document the current one.
activate |= shown.is_some_and(|s| s.response.contains_pointer()) && ctx.input(|inp| inp.pointer.any_pressed());
Shown { close: !open, activate, focused: false, body: rect, fit }
activate |= shown.as_ref().is_some_and(|s| s.response.contains_pointer()) && ctx.input(|inp| inp.pointer.any_pressed());
// Moved by its title bar onto the main window's tab strip, it goes back under the tabs.
// egui moves a window by the drag of its title bar's own widget, or, where it moves windows by
// a drag anywhere (after touch input), by the drag of the window itself.
let mover = ctx.read_response(id.with("__title_click")).or_else(|| shown.as_ref().map(|s| s.response.clone()));
let moved = mover.map_or((false, false), |r| (r.dragged(), r.drag_stopped()));
let pointer = ctx.input(|inp| inp.pointer.latest_pos());
let over_strip = !blocked && (moved.0 || moved.1) && app.canvas.windows.strip.zip(pointer).is_some_and(|(strip, p)| strip.contains(p));
Shown { close: !open, activate, over_strip, dock: over_strip && moved.1, focused: false, body: rect, fit }
}
/// The host's rewrites of raw input ([`crate::services::Services::native_input_hook`]) for the
+209 -9
View File
@@ -60,7 +60,7 @@ fn body_of(app: &VectorcraftApp, i: usize) -> egui::Rect {
fn window_of(app: &VectorcraftApp, ctx: &egui::Context, i: usize) -> egui::Rect {
let uid = app.session.documents()[i].uid;
let w = app.ui.floating.iter().find(|w| w.uid == uid).unwrap();
ctx.memory(|m| m.area_rect(egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation)))).unwrap()
ctx.memory(|m| m.area_rect(window_id(w))).unwrap()
}
/// A drag with the primary button from `from` to `to`, then a frame to settle.
@@ -279,7 +279,7 @@ fn closing_a_floating_window_closes_its_document_and_asks_to_save_an_unsaved_one
let close_button = |app: &VectorcraftApp, i: usize| {
let uid = app.session.documents()[i].uid;
let w = app.ui.floating.iter().find(|w| w.uid == uid).unwrap();
let area = ctx.memory(|m| m.area_rect(egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation)))).unwrap();
let area = ctx.memory(|m| m.area_rect(window_id(w))).unwrap();
let body = w.body.unwrap();
(area, body)
};
@@ -363,7 +363,7 @@ fn a_document_made_current_in_presentation_mode_comes_to_the_front_after_it() {
let layer = |app: &VectorcraftApp, i: usize| {
let uid = app.session.documents()[i].uid;
let w = app.ui.floating.iter().find(|w| w.uid == uid).unwrap();
egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation)))
egui::LayerId::new(egui::Order::Middle, window_id(w))
};
let top = |ctx: &egui::Context| ctx.memory(|m| m.areas().top_layer_id(egui::Order::Middle));
assert_eq!(top(&ctx), Some(layer(&app, 1)), "the current document's window is in front");
@@ -378,11 +378,15 @@ fn a_document_made_current_in_presentation_mode_comes_to_the_front_after_it() {
}
/// A stand-in for the window system of the documents' OS windows: it runs each one's frame, asks
/// each to close while `close` is set, and records the commands the windows were sent.
/// each to close while `close` is set, and records the commands the windows were sent. Each
/// window is 500 × 400 points with its top-left corner at `origin` on the screen, and its next
/// frame takes `events`.
#[derive(Default)]
struct Host {
close: bool,
commands: Vec<(egui::ViewportId, egui::ViewportCommand)>,
origin: Pos2,
events: Vec<egui::Event>,
}
fn native_host() -> std::rc::Rc<std::cell::RefCell<Host>> {
@@ -390,11 +394,25 @@ fn native_host() -> std::rc::Rc<std::cell::RefCell<Host>> {
let shared = host.clone();
egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
let id = viewport.ids.this;
let events = if shared.borrow().close { vec![egui::ViewportEvent::Close] } else { vec![] };
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(500.0, 400.0));
let info = egui::ViewportInfo { events, inner_rect: Some(rect), outer_rect: Some(rect), focused: Some(false), ..Default::default() };
let input =
egui::RawInput { viewport_id: id, viewports: std::iter::once((id, info)).collect(), screen_rect: Some(rect), ..Default::default() };
let close = if shared.borrow().close { vec![egui::ViewportEvent::Close] } else { vec![] };
let size = vec2(500.0, 400.0);
let rect = egui::Rect::from_min_size(Pos2::ZERO, size);
let on_screen = egui::Rect::from_min_size(shared.borrow().origin, size);
let info = egui::ViewportInfo {
events: close,
inner_rect: Some(on_screen),
outer_rect: Some(on_screen),
focused: Some(false),
..Default::default()
};
let events = std::mem::take(&mut shared.borrow_mut().events);
let input = egui::RawInput {
viewport_id: id,
viewports: std::iter::once((id, info)).collect(),
screen_rect: Some(rect),
events,
..Default::default()
};
let mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui));
full.textures_delta.clear();
let mut host = shared.borrow_mut();
@@ -433,6 +451,188 @@ fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() {
);
}
/// Where the last frame drew the title of `title`'s tab in the tab strip.
fn tab_at(shapes: &[Shape], title: &str) -> Pos2 {
shapes
.iter()
.find_map(|s| match s {
Shape::Text(t) if t.pos.y < 35.0 && t.galley.text().contains(title) => Some(t.pos + vec2(4.0, 4.0)),
_ => None,
})
.unwrap()
}
#[test]
fn a_tab_is_torn_off_only_well_above_or_below_the_strip() {
let strip = egui::Rect::from_min_max(pos2(0.0, 40.0), pos2(1200.0, 75.0));
assert!(!torn_off(strip, pos2(300.0, 60.0)), "along the strip");
assert!(!torn_off(strip, pos2(300.0, 75.0 + TEAR_OFF - 1.0)), "a wobble below the strip");
assert!(torn_off(strip, pos2(300.0, 75.0 + TEAR_OFF + 1.0)));
assert!(torn_off(strip, pos2(300.0, 40.0 - TEAR_OFF - 1.0)), "dragged up");
}
#[test]
fn dragging_a_tab_off_the_strip_floats_its_document_at_the_pointer() {
let mut app = app_with(3);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let shapes = frame(&mut app, &ctx, vec![]);
let title = app.session.documents()[0].title();
let tab = tab_at(&shapes, &title);
// A short drag, and a drag along the strip, leave the tabs as they are.
drag(&mut app, &ctx, tab, tab + vec2(0.0, 35.0 + TEAR_OFF - 10.0 - tab.y));
drag(&mut app, &ctx, tab, tab + vec2(300.0, 0.0));
assert!(app.ui.floating.is_empty(), "the document stays under its tab");
assert_eq!(app.session.active_index(), Some(2), "a drag is not a click");
// Dragged well below the strip, it floats in a window under the pointer and becomes current.
let to = pos2(300.0, 200.0);
drag(&mut app, &ctx, tab, to);
assert_eq!(docked(&app), [1, 2]);
assert_eq!(app.session.active_index(), Some(0), "the torn-off document is the current one");
assert_eq!(app.ui.floating[0].place.min, to - GRAB, "its window opens under the pointer");
frame(&mut app, &ctx, vec![]);
let shapes = frame(&mut app, &ctx, vec![]);
assert!(!tab_drawn(&shapes, &title), "it has left the strip");
let window = window_of(&app, &ctx, 0);
assert!(window.contains(to) && (window.min - (to - GRAB)).length() < 1.0, "the pointer is over its window: {window:?}");
assert!(body_of(&app, 0).contains_rect(app.canvas_rect.unwrap()), "its window holds the canvas");
}
#[test]
fn a_window_dragged_by_its_title_bar_onto_the_strip_goes_back_under_the_tabs() {
let mut app = app_with(3);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.floatAllInWindows", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
let title_bar = |app: &VectorcraftApp, i: usize| {
let (window, body) = (window_of(app, &ctx, i), body_of(app, i));
pos2(window.left() + 60.0, (window.top() + body.top()) / 2.0)
};
// Moved elsewhere, document 2's window (in front) stays a window.
let from = title_bar(&app, 2);
drag(&mut app, &ctx, from, from + vec2(40.0, 30.0));
assert_eq!(app.ui.floating.len(), 3, "a window moved off the strip still floats");
// Dropped on the strip, it goes back under its tab, current; the other windows stay.
let from = title_bar(&app, 2);
drag(&mut app, &ctx, from, pos2(600.0, 15.0));
assert_eq!(docked(&app), [2]);
assert_eq!(app.session.active_index(), Some(2));
assert_eq!(app.ui.floating.len(), 2);
frame(&mut app, &ctx, vec![]);
let shapes = frame(&mut app, &ctx, vec![]);
assert!(tab_drawn(&shapes, &app.session.documents()[2].title()), "it has its tab again");
assert!(app.views[2].fitted, "it fits the canvas under the tabs");
let canvas = app.canvas_rect.unwrap();
assert!(canvas.top() >= 35.0 && canvas.width() > 1000.0, "the canvas under the tabs shows it: {canvas:?}");
// Torn off again, it opens where its tab is dropped, not where its last window was.
let to = pos2(200.0, 300.0);
let tab = tab_at(&shapes, &app.session.documents()[2].title());
drag(&mut app, &ctx, tab, to);
frame(&mut app, &ctx, vec![]);
let window = window_of(&app, &ctx, 2);
assert!((window.min - (to - GRAB)).length() < 1.0, "its new window is under the pointer: {window:?}");
}
#[test]
fn after_touch_input_a_window_dragged_onto_the_strip_still_docks() {
let mut app = app_with(2);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.floatAllInWindows", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
// A touch makes egui move windows by a drag anywhere on them.
let touch = egui::Event::Touch {
device_id: egui::TouchDeviceId(1),
id: egui::TouchId(1),
phase: egui::TouchPhase::Start,
pos: pos2(1100.0, 700.0),
force: None,
};
frame(&mut app, &ctx, vec![touch]);
frame(&mut app, &ctx, vec![]);
let (window, body) = (window_of(&app, &ctx, 1), body_of(&app, 1));
let from = pos2(window.left() + 60.0, (window.top() + body.top()) / 2.0);
drag(&mut app, &ctx, from, pos2(600.0, 15.0));
assert_eq!(docked(&app), [1], "document 1 is back under the tabs");
}
#[test]
fn a_document_floated_again_after_cascade_opens_where_it_is_asked_to() {
let mut app = app_with(2);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.floatAllInWindows", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.cascade", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.consolidateAllWindows", json!({"index": 1})).unwrap();
frame(&mut app, &ctx, vec![]);
let at = pos2(200.0, 300.0);
app.run("window.arrange.floatInWindow", json!({"index": 1, "at": [at.x, at.y]})).unwrap();
frame(&mut app, &ctx, vec![]);
let window = window_of(&app, &ctx, 1);
assert!((window.min - at).length() < 1.0, "its new window opens at {at:?}: {window:?}");
}
#[test]
fn float_in_window_and_consolidate_take_a_document() {
let mut app = app_with(3);
assert_eq!(app.run("window.arrange.floatInWindow", json!({"index": 0, "at": [100, 120]})).unwrap(), json!({"floating": [0]}));
assert_eq!(app.session.active_index(), Some(0), "document 0 becomes the current one");
assert_eq!(app.ui.floating[0].place.min, pos2(100.0, 120.0));
app.run("window.arrange.floatInWindow", json!({"index": 1})).unwrap();
assert_eq!(app.run("window.arrange.floatInWindow", json!({"index": 3})), Err("no such document".to_string()));
app.run("window.arrange.tile", json!({})).unwrap();
app.run("window.arrange.floatAllInWindows", json!({})).unwrap();
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({"index": 1})).unwrap(), json!({"tiled": true, "floating": [0, 2]}));
assert_eq!(app.session.active_index(), Some(1));
assert_eq!(docked(&app), [1], "only document 1 goes back under the tabs");
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({"index": 7})), Err("no such document".to_string()));
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(), json!({"tiled": false, "floating": []}));
}
#[test]
fn an_os_window_moves_by_its_tab_and_docks_on_the_strip() {
let host = native_host();
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let mut app = app_with(2);
if !native(&app, &ctx) {
// Wayland: the windows are inside the main window.
return;
}
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.floatInWindow", json!({})).unwrap();
// The OS window's top-left corner is at (300, 300) on the screen; the main window's at (0, 0).
host.borrow_mut().origin = pos2(300.0, 300.0);
frame(&mut app, &ctx, vec![]);
let os_drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
let steps = [vec![egui::Event::PointerMoved(from)], vec![button(from, true)]]
.into_iter()
.chain((1..=4).map(|k| vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))]))
.chain([vec![button(to, false)], vec![]]);
for events in steps {
host.borrow_mut().events = events;
frame(app, &ctx, vec![]);
}
};
// The tab at the top of the window: dragged down and right, the window follows the pointer.
let tab = pos2(20.0, 15.0);
os_drag(&mut app, tab, tab + vec2(100.0, 50.0));
assert!(is_floating(&app, 1), "a window moved off the strip still floats");
let w = &app.ui.floating[0];
assert_eq!(w.screen.map(|s| s.0), Some(pos2(400.0, 350.0)), "the window moved with the pointer");
assert_eq!(w.place.min, pos2(400.0, 350.0), "in the main window's points");
// Released over the main window's tab strip (y 0 to 35 on the screen), it docks.
os_drag(&mut app, tab, pos2(20.0, -280.0));
assert!(app.ui.floating.is_empty(), "the document is back under the tabs");
assert_eq!(docked(&app), [0, 1]);
assert_eq!(app.session.active_index(), Some(1));
}
#[test]
fn the_arrange_items_are_enabled_as_the_documents_allow() {
let ids = [
+2 -1
View File
@@ -547,8 +547,9 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
"window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()),
"window.arrange.cascade" => crate::doc_windows::cascade(app),
"window.arrange.tile" => crate::canvas::tile(app),
"window.arrange.floatInWindow" => crate::doc_windows::float_in_window(app),
"window.arrange.floatInWindow" => crate::doc_windows::float_in_window(app, p),
"window.arrange.floatAllInWindows" => crate::doc_windows::float_all(app),
"window.arrange.consolidateAllWindows" if p.get("index").is_some_and(|i| !i.is_null()) => crate::doc_windows::dock(app, p),
"window.arrange.consolidateAllWindows" => crate::canvas::consolidate(app),
"window.nextDocument" => crate::chrome::step_document(app, true),
"window.previousDocument" => crate::chrome::step_document(app, false),
+2 -2
View File
@@ -597,7 +597,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.arrange.floatInWindow",
"Float in Window",
"",
"{} move the current document out of the tab strip into a floating window of its own, fitted to it; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)",
"{index?, at?: [x, y]} move the current document, or document `index` (which becomes the current one), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)",
),
(
"window.arrange.floatAllInWindows",
@@ -609,7 +609,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.arrange.consolidateAllWindows",
"Consolidate All Windows",
"",
"{} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}",
"{index?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)",
),
(
"window.nextDocument",