Files
vectorcraft/crates/ui-egui/src/chrome/tabs.rs
T
dotCooCoo ca8aba6d6a 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.
2026-11-03 16:03:39 +07:00

415 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{Sense, Stroke, Tokens, Ui, VectorcraftApp, icons, json, theme, vec2, widgets, zoom_label};
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
pub(super) fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String {
let mode = if d.doc.mask_edit.is_some() {
format!("<{0}>/{0}", tl!("Opacity Mask"))
} else {
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") })
};
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %"))
}
/// The title of open document `i`'s tab, with its own view's zoom and Outline or Preview.
pub(super) fn doc_tab_title(app: &VectorcraftApp, i: usize, d: &vectorcraft_engine::DocState) -> String {
let view = app.views.get(i);
let zoom = view.map_or(1.0, |v| v.zoom);
tab_title(d, zoom, view.is_some_and(|v| v.outline), app.session.prefs.display_print_size)
}
/// The title of document `i`'s floating window ([`crate::doc_windows`]): its tab's title.
pub(crate) fn document_window_title(app: &VectorcraftApp, i: usize) -> Option<String> {
let d = app.session.documents().get(i)?;
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");
/// The first of tabs `widths` wide that the strip shows in `room`: the first one, or a later one so
/// that the `active` tab fits.
pub(super) fn first_tab_shown(widths: &[f32], active: Option<usize>, room: f32) -> usize {
let Some(a) = active else { return 0 };
let mut first = 0;
while first < a && widths.get(first..=a).map_or(0.0, |w| w.iter().sum::<f32>()) > room {
first += 1;
}
first
}
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs
/// 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. 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) = 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.
let shown = crate::doc_windows::tab_document(app).filter(|_| app.ui.home.is_none());
// The documents under the tabs, by index; `active` is the position of the highlighted one.
let docked = crate::doc_windows::docked(app);
let active = docked.iter().position(|i| Some(*i) == shown);
let tabs: Vec<(String, std::sync::Arc<egui::Galley>)> = docked
.iter()
.filter_map(|i| Some((*i, app.session.documents().get(*i)?)))
.map(|(i, d)| {
let title = doc_tab_title(app, i, d);
let color = if Some(i) == shown { t.text_strong } else { t.text_dim };
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
})
.collect();
let widths: Vec<f32> = tabs.iter().map(|(_, g)| g.size().x + 50.0).collect();
let overflow = widths.iter().sum::<f32>() > strip.width();
let room = strip.width() - if overflow { MORE } else { 0.0 };
let first = if overflow { first_tab_shown(&widths, active, room) } else { 0 };
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 {
break;
}
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_and_drag());
if is_active {
ui.painter().rect_filled(r, 0.0, t.panel);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
}
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.5, t.border));
// The × where each platform puts a tab's: before the title on macOS, after it elsewhere
// (#673).
let (x_center, title_left) = if CLOSE_BEFORE_TITLE { (r.left() + 16.0, r.left() + 32.0) } else { (r.right() - 16.0, r.left() + 14.0) };
let xr = egui::Rect::from_center_size(egui::pos2(x_center, r.center().y), vec2(12.0, 12.0));
let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i)), Sense::click());
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text);
hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0));
if xresp.clicked() {
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;
}
crate::document_drag::finish_tabs(ui.ctx(), hovering_art);
// The » button: every open document, floating ones included, the active one checked. The list
// scrolls once it reaches the bottom of the window, so every document of a large session stays
// reachable (#4017).
if overflow {
let current = app.session.active_index().filter(|_| app.ui.home.is_none());
let titles: Vec<String> = (0..app.session.documents().len()).map(|i| document_window_title(app, i).unwrap_or_default()).collect();
let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0));
let resp = ui.interact(more, ui.scope_id().with("tabs-more"), Sense::click());
ui.painter().rect_filled(more, 0.0, if resp.hovered() { t.hover.gamma_multiply(0.4) } else { t.tab_strip });
icons::paint(ui, "chevrons-right", egui::Rect::from_center_size(more.center(), vec2(14.0, 14.0)), t.text);
let resp = resp.on_hover_text(tl!("Show all open documents"));
egui::Popup::menu(&resp).show(|ui| {
widgets::menu_scroll(ui, |ui| {
for (i, title) in titles.iter().enumerate() {
if widgets::menu_item_name(ui, title, true, Some(i) == current) {
activate = Some(i);
ui.close();
}
}
});
});
}
if let Some(i) = close {
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);
}
}
/// Makes document `i` the current one, as a click on its tab does: the Home screen closes, and
/// with the Artboard tool the artboard the document shows becomes the tool's target.
pub(crate) fn show_document(app: &mut VectorcraftApp, i: usize) {
app.ui.home = None;
let changed = app.session.active_index() != Some(i);
if app.session.set_active(i)
&& changed
&& app.session.tool_id() == "artboard"
&& let Some(n) = app.session.active().map(|d| d.doc.artboards.len())
{
// A document switch recreates the tool; restore the displayed board as its target.
// Clicking the current tab keeps its existing multiselection and tool options.
let selected = crate::panels::artboards::selected(app, n);
crate::panels::artboards::select(app, selected);
}
}
/// `window.nextDocument` (`forward`) and `window.previousDocument`: makes the open document after
/// the current one in tab order (before it) the current one, as a click on its tab does. After the
/// last document comes the first, and before the first document comes the last. On the Home
/// screen, where no tab is the current one, the next document is the first and the previous
/// document is the last. Returns `{index}`.
pub fn step_document(app: &mut VectorcraftApp, forward: bool) -> Result<serde_json::Value, String> {
let n = app.session.documents().len();
if n == 0 {
return Err("no document open".into());
}
let i = match (app.session.active_index().filter(|_| app.ui.home.is_none()), forward) {
(Some(a), true) => (a + 1) % n,
(Some(a), false) => (a + n - 1) % n,
(None, true) => 0,
(None, false) => n - 1,
};
show_document(app, i);
Ok(json!({ "index": i }))
}
/// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the
/// canvas, so entering isolation mode does not change the canvas's size (#3126).
pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) {
let t = Tokens::get(ui.ctx());
if let Some(st) = app.session.active()
&& let Some(iso) = st.isolation
{
let ancestry = st.doc.ancestry(iso).unwrap_or_default();
struct Crumb {
id: Option<vectorcraft_doc::NodeId>,
is_layer: bool,
name: String,
}
let mut crumbs: Vec<Crumb> = ancestry
.iter()
.filter_map(|id| st.doc.node(*id).map(|n| Crumb { id: Some(*id), is_layer: n.is_layer(), name: n.display_name() }))
.collect();
// Pattern editing mode: the pattern's name, and Save a Copy / Done / Cancel at the right.
let pattern_edit = st.doc.pattern_edit.as_ref().map(|e| e.pattern.clone());
if let Some(pn) = &pattern_edit {
crumbs.push(Crumb { id: None, is_layer: false, name: pn.clone() });
}
let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0));
// Keeps the pointer off the canvas; not focusable (`click_and_drag` is), so Tab skips it.
ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::CLICK | Sense::DRAG);
ui.painter().rect_filled(bar, 0.0, t.panel);
let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0));
let bresp = ui.interact(back, ui.scope_id().with("iso-back"), Sense::click());
icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon });
let mut x = bar.left() + 30.0;
let y_center = bar.center().y;
let font_id = egui::FontId::proportional(12.0);
let mut nav_cmd = None;
let total = crumbs.len();
for (i, crumb) in crumbs.iter().enumerate() {
let is_leaf = i + 1 == total;
let galley = ui.painter().layout_no_wrap(crumb.name.clone(), font_id.clone(), t.text);
let w = galley.size().x;
let text_pos = egui::pos2(x, y_center - galley.size().y * 0.5);
if !is_leaf {
let crumb_rect = egui::Rect::from_min_max(egui::pos2(x - 3.0, bar.top() + 3.0), egui::pos2(x + w + 3.0, bar.bottom() - 3.0));
let resp = ui.interact(crumb_rect, ui.scope_id().with(("iso-crumb", i)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(crumb_rect, 3.0, t.hover);
}
if resp.clicked() {
if crumb.is_layer {
nav_cmd = Some(("object.exitIsolation", json!({})));
} else if let Some(target) = crumb.id {
nav_cmd = Some(("object.isolate", json!({ "id": target.0 })));
}
}
ui.painter().galley(text_pos, galley, if resp.hovered() { t.text } else { t.text_dim });
x += w + 6.0;
let sep = ui.painter().layout_no_wrap("›".to_string(), font_id.clone(), t.text_dim);
let sw = sep.size().x;
ui.painter().galley(egui::pos2(x, y_center - sep.size().y * 0.5), sep, t.text_dim);
x += sw + 6.0;
} else {
ui.painter().galley(text_pos, galley, t.text);
x += w;
}
}
let mut pattern_cmd = None;
if pattern_edit.is_some() {
let mut x = bar.right() - 6.0;
for (label, cmd) in [("Cancel", "object.pattern.cancel"), ("Done", "object.pattern.done"), ("Save a Copy", "object.pattern.saveCopy")] {
let label = tl!(label);
let w = 12.0 + 7.0 * label.len() as f32;
let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0));
let resp = ui.interact(r, ui.scope_id().with(("pat-bar", cmd)), Sense::click());
ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.panel });
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside);
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), t.text);
if resp.clicked() {
pattern_cmd = Some(cmd);
}
x -= w + 6.0;
}
}
if bresp.clicked() {
pattern_cmd = pattern_cmd.or(pattern_edit.as_ref().map(|_| "object.pattern.done"));
if pattern_cmd.is_none() {
// Up one level: the isolated object's parent, or out of isolation mode from a layer.
nav_cmd = Some(match ancestry.iter().rev().nth(1).filter(|p| st.doc.node(**p).is_some_and(|n| !n.is_layer())) {
Some(parent) => ("object.isolate", json!({ "id": parent.0 })),
None => ("object.exitIsolation", json!({})),
});
}
}
if let Some((cmd, params)) = nav_cmd {
app.run(cmd, params).ok();
}
if let Some(cmd) = pattern_cmd {
app.run(cmd, json!({})).ok();
}
}
}
/// Draws the artboard navigator's field, which shows the active artboard (`cur`) by number and
/// name. The field's list shows every artboard by number and name, as the Artboards panel lists
/// them, and opens with the active artboard's row in view. The list lays out only the rows in view
/// and at most one more. Returns the artboard chosen in the list.
pub(super) fn artboard_list(app: &VectorcraftApp, ui: &mut Ui, cur: usize) -> Option<usize> {
/// The width of the field.
const FIELD_W: f32 = 140.0;
/// The least width of the list.
const LIST_W: f32 = 220.0;
/// The height of a row, as in the Artboards panel.
const ROW_H: f32 = 24.0;
/// The rows the list shows at once.
const ROWS: f32 = 10.0;
let t = Tokens::get(ui.ctx());
let boards = app.session.active().map(|d| d.doc.artboards.as_slice()).unwrap_or_default();
let shown = boards.get(cur).map_or_else(|| "–".to_string(), |a| format!("{} {}", cur + 1, a.name));
// Under `drawn`, egui's temp data stores the pass the list was last drawn in.
let drawn = ui.scope_id().with("artboard-list");
let height = ROW_H * ROWS;
ui.add_enabled_ui(!boards.is_empty(), |ui| {
ui.set_max_width(FIELD_W);
ui.name_inputs(tl!("Artboard"), |ui| {
craft_ui::ComboBox::from_id_salt("artboard-list")
.selected_text(egui::RichText::new(shown).size(11.5))
.width(FIELD_W)
.wrap_mode(egui::TextWrapMode::Truncate)
.height(height)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show_ui(ui, |ui| {
let pass = ui.ctx().cumulative_pass_nr();
let last = ui.data(|d| d.get_temp::<u64>(drawn)).unwrap_or_default();
ui.data_mut(|d| d.insert_temp(drawn, pass));
let mut area = egui::ScrollArea::vertical().max_height(height);
// As the list opens, it scrolls the active row as close to the middle as the list allows.
if last + 1 < pass || last == 0 {
area = area.vertical_scroll_offset((cur as f32 * ROW_H - (height - ROW_H) * 0.5).max(0.0));
}
// Every number ends where the widest one ends.
let numbers = ui.painter().layout_no_wrap(boards.len().to_string(), egui::FontId::proportional(12.0), t.text_dim).size().x;
let width = ui.available_width().max(LIST_W);
let mut chosen = None;
ui.spacing_mut().item_spacing.y = 0.0;
area.show_rows(ui, ROW_H, boards.len(), |ui, range| {
for i in range {
let Some(board) = boards.get(i) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click());
if i == cur {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let number_end = r.left() + 8.0 + numbers;
ui.painter().text(
egui::pos2(number_end, r.center().y),
egui::Align2::RIGHT_CENTER,
(i + 1).to_string(),
egui::FontId::proportional(12.0),
t.text_dim,
);
let name_start = number_end + 10.0;
let name = egui::WidgetText::from(egui::RichText::new(&board.name).size(12.5)).into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
r.right() - 8.0 - name_start,
egui::TextStyle::Body,
);
ui.painter().galley(egui::pos2(name_start, r.center().y - name.size().y / 2.0), name, t.text);
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == cur, format!("{} {}", i + 1, board.name)));
if resp.clicked() {
chosen = Some(i);
}
}
});
if chosen.is_some() {
ui.close();
}
chosen
})
.inner
.flatten()
})
})
.inner
}