mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Merge pull request #437 from bobtherobot/feat/floating-flyouts
Toolbar: tool flyouts tear off into floating toolbars
This commit is contained in:
+2
-1
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -97,6 +97,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
|
||||
@@ -604,3 +604,7 @@
|
||||
Version Verze
|
||||
% of all commits % všech commitů
|
||||
Lines +/− Řádky +/−
|
||||
Drag to move Přetažením přesunete
|
||||
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
|
||||
Put back in the toolbar Vrátit do panelu nástrojů
|
||||
Float Tool Group Plovoucí skupina nástrojů
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3029,3 +3029,7 @@
|
||||
axis Horizontal Horizontal
|
||||
axis Vertical Vertical
|
||||
axis Both Ambos
|
||||
Drag to move Arrastrar para mover
|
||||
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
|
||||
Put back in the toolbar Devolver a la barra de herramientas
|
||||
Float Tool Group Hacer flotar el grupo de herramientas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3018,3 +3018,7 @@
|
||||
axis Horizontal 水平方向
|
||||
axis Vertical 垂直方向
|
||||
axis Both 両方
|
||||
Drag to move 移動するにはドラッグ
|
||||
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
|
||||
Put back in the toolbar ツールバーに戻す
|
||||
Float Tool Group ツールグループをフローティング
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2621,3 +2621,7 @@
|
||||
Version Versão
|
||||
% of all commits % de todos os commits
|
||||
Lines +/− Linhas +/−
|
||||
Drag to move Arraste para mover
|
||||
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
|
||||
Put back in the toolbar Devolver à barra de ferramentas
|
||||
Float Tool Group Flutuar Grupo de Ferramentas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3275,3 +3275,7 @@
|
||||
axis Horizontal 水平
|
||||
axis Vertical 垂直
|
||||
axis Both 两者
|
||||
Drag to move 拖动以移动
|
||||
Drag to float as a toolbar 拖动使其浮动为工具栏
|
||||
Put back in the toolbar 放回工具栏
|
||||
Float Tool Group 浮动工具组
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3049,3 +3049,7 @@
|
||||
axis Horizontal 水平
|
||||
axis Vertical 垂直
|
||||
axis Both 兩者
|
||||
Drag to move 拖曳以移動
|
||||
Drag to float as a toolbar 拖曳使其浮動為工具列
|
||||
Put back in the toolbar 放回工具列
|
||||
Float Tool Group 浮動工具組
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -199,6 +199,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("window.toolbar", "Tools", "", "{}"),
|
||||
("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"),
|
||||
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
|
||||
(
|
||||
"window.floatTools",
|
||||
"Float Tool Group",
|
||||
"",
|
||||
"{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
|
||||
),
|
||||
("window.taskBar", "Contextual Task Bar", "", "{}"),
|
||||
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
|
||||
@@ -980,6 +986,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"window.toolbar" => flag(&mut app.ui.toolbar),
|
||||
"window.toolbarColumns" => flag(&mut app.ui.toolbar_double),
|
||||
"window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
|
||||
"window.floatTools" => {
|
||||
let floating = match p.get("floating") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Bool(b)) => Some(*b),
|
||||
Some(_) => return Some(Err("floating must be true or false".into())),
|
||||
};
|
||||
let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };
|
||||
crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)
|
||||
}
|
||||
"window.taskBar" => flag(&mut app.ui.task_bar),
|
||||
"window.dock" => {
|
||||
let on = !(app.ui.dock && app.ui.toolbar);
|
||||
@@ -1302,6 +1317,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
"window.control" => app.ui.control_bar,
|
||||
"window.toolbar" => app.ui.toolbar,
|
||||
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
|
||||
"window.floatTools" => {
|
||||
let tool = p.get("tool").and_then(Value::as_str).unwrap_or("");
|
||||
app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool))
|
||||
}
|
||||
"window.taskBar" => app.ui.task_bar,
|
||||
"window.panel" => {
|
||||
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
@@ -255,6 +255,9 @@ pub struct UiState {
|
||||
/// Last tool shown in each toolbar slot (keyed by the slot's first tool id).
|
||||
#[serde(default)]
|
||||
pub slot_tool: std::collections::BTreeMap<String, String>,
|
||||
/// Tool flyouts torn off the toolbar into floating panels.
|
||||
#[serde(default)]
|
||||
pub floating_flyouts: Vec<FloatingFlyout>,
|
||||
pub status_bar: bool,
|
||||
pub dock: bool,
|
||||
pub view: ViewFlags,
|
||||
@@ -368,6 +371,15 @@ pub struct WindowGeometry {
|
||||
pub maximized: bool,
|
||||
}
|
||||
|
||||
/// A tool group's flyout torn off the toolbar: it floats as its own panel until its × puts it back.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FloatingFlyout {
|
||||
/// The group's tools in flyout order; the first one names the toolbar slot.
|
||||
pub tools: Vec<String>,
|
||||
/// Top-left corner in screen points.
|
||||
pub pos: [f32; 2],
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// Clear transient state after loading saved preferences.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
@@ -380,6 +392,12 @@ impl UiState {
|
||||
if self.group_tool.len() != vectorcraft_tools::TOOL_GROUPS.len() {
|
||||
self.group_tool = UiState::default().group_tool;
|
||||
}
|
||||
// One strip per group, of known tools (a hand-edited preferences file).
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
self.floating_flyouts.retain_mut(|f| {
|
||||
f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());
|
||||
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -398,6 +416,7 @@ impl Default for UiState {
|
||||
toolbar_advanced: false,
|
||||
task_bar: true,
|
||||
slot_tool: Default::default(),
|
||||
floating_flyouts: vec![],
|
||||
status_bar: true,
|
||||
dock: true,
|
||||
view: ViewFlags::default(),
|
||||
|
||||
+500
-44
@@ -14,6 +14,16 @@ const PITCH: f32 = 30.0;
|
||||
const WIDTH: f32 = 48.0;
|
||||
/// Seconds a press on a tool group's button is held before its flyout opens.
|
||||
const LONG_PRESS: f64 = 0.35;
|
||||
/// Width of the grab bar down a flyout's right side: dragging it tears the flyout off.
|
||||
const BAR: f32 = 10.0;
|
||||
/// Height of the × at the top of a floating flyout's bar.
|
||||
const CLOSE: f32 = 14.0;
|
||||
/// Points the pointer travels on a flyout's bar before the flyout tears off.
|
||||
const TEAR: f32 = 3.0;
|
||||
/// Seconds a floating flyout flashes when a press that would open its flyout raises it.
|
||||
const FLASH: f64 = 0.4;
|
||||
/// Size of a tool button in a floating flyout's strip.
|
||||
const STRIP_BUTTON: egui::Vec2 = vec2(32.0, PITCH);
|
||||
|
||||
/// The Basic toolbar: (category, slots); each slot is a flyout group (first = default).
|
||||
pub const BASIC: &[(&str, &[&[&str]])] = &[
|
||||
@@ -118,6 +128,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
|
||||
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
|
||||
// A group torn off into a floating flyout is raised instead of opening its flyout.
|
||||
let mut raise: Option<&'static str> = None;
|
||||
let mut i = 0;
|
||||
while i < all.len() {
|
||||
if let Some(cat) = all[i].0 {
|
||||
@@ -172,7 +184,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
|
||||
let alt = ui.input(|inp| inp.modifiers.alt);
|
||||
if press.is_some() {
|
||||
open_flyout = Some((slot.clone(), rect));
|
||||
if is_floating(app, slot[0]) {
|
||||
raise = Some(slot[0]);
|
||||
} else {
|
||||
open_flyout = Some((slot.clone(), rect));
|
||||
}
|
||||
if press == Some(FlyoutPress::Primary) {
|
||||
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
|
||||
}
|
||||
@@ -192,6 +208,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
i += row.len();
|
||||
}
|
||||
if let Some(key) = raise {
|
||||
raise_floating(ui.ctx(), key);
|
||||
}
|
||||
if let Some((slot, rect)) = open_flyout {
|
||||
app.ui.flyout = Some(0);
|
||||
ui.data_mut(|d| {
|
||||
@@ -203,6 +222,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
bottom_controls(app, ui, &t);
|
||||
});
|
||||
});
|
||||
// Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two
|
||||
// layers in one frame).
|
||||
floating(app, ui.ctx());
|
||||
flyout(app, ui.ctx());
|
||||
}
|
||||
|
||||
@@ -360,6 +382,183 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
||||
let _ = t;
|
||||
}
|
||||
|
||||
/// Where `window.floatTools` puts a strip (the next one a row lower), right of the toolbar; it's
|
||||
/// kept on screen and moved by its bar.
|
||||
const FLOAT_AT: [f32; 2] = [90.0, 100.0];
|
||||
|
||||
/// Float the toolbar group (of the current layout) holding `tool` as a strip of tool buttons, put
|
||||
/// it back in the toolbar, or toggle (`floating` omitted), as a flyout's tear-off bar and the
|
||||
/// strip's × do (`window.floatTools`). Returns the new state.
|
||||
pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>) -> Result<bool, String> {
|
||||
let Some((_, group)) = slots(app).into_iter().find(|(_, s)| s.len() > 1 && s.contains(&tool)) else {
|
||||
return Err(format!("{tool} isn't in a toolbar group of several tools"));
|
||||
};
|
||||
let key = group.first().copied().unwrap_or_default();
|
||||
let was = is_floating(app, key);
|
||||
let on = floating.unwrap_or(!was);
|
||||
if on && !was {
|
||||
let n = app.ui.floating_flyouts.len() as f32;
|
||||
let tools = group.iter().map(|id| id.to_string()).collect();
|
||||
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] });
|
||||
app.ui.flyout = None;
|
||||
} else if !on {
|
||||
app.ui.floating_flyouts.retain(|f| f.tools.first().is_none_or(|k| k != key));
|
||||
}
|
||||
Ok(on)
|
||||
}
|
||||
|
||||
/// Whether the group whose first tool is `key` floats as its own panel.
|
||||
fn is_floating(app: &VectorcraftApp, key: &str) -> bool {
|
||||
app.ui.floating_flyouts.iter().any(|f| f.tools.first().is_some_and(|k| k == key))
|
||||
}
|
||||
|
||||
/// The area of the floating flyout of the group whose first tool is `key`.
|
||||
fn floating_area(key: &str) -> egui::Id {
|
||||
egui::Id::new(("tool-floating", key))
|
||||
}
|
||||
|
||||
/// Bring a floating flyout to the front and flash its border.
|
||||
fn raise_floating(ctx: &egui::Context, key: &str) {
|
||||
let id = floating_area(key);
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
|
||||
let now = ctx.input(|i| i.time);
|
||||
ctx.data_mut(|d| d.insert_temp(id, now));
|
||||
}
|
||||
|
||||
/// A flyout's frame: drop shadow and border (the accent while it flashes).
|
||||
fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(if flash { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.input_border) })
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) })
|
||||
}
|
||||
|
||||
/// What a press on a flyout did.
|
||||
#[derive(Default)]
|
||||
struct FlyoutInput {
|
||||
/// The tool row clicked.
|
||||
chosen: Option<&'static str>,
|
||||
/// The grab bar's response (its drag tears off or moves the flyout).
|
||||
bar: Option<egui::Response>,
|
||||
/// A floating flyout's × was clicked.
|
||||
close: bool,
|
||||
}
|
||||
|
||||
/// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their
|
||||
/// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag
|
||||
/// that tears a flyout off carries on moving the floating flyout it becomes.
|
||||
fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool) -> FlyoutInput {
|
||||
let mut out = FlyoutInput::default();
|
||||
let key = tools.first().map_or("", String::as_str);
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
ui.horizontal(|ui| {
|
||||
let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) };
|
||||
out.chosen = rows.inner;
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover());
|
||||
ui.painter().rect_filled(bar, 0.0, t.tab_strip);
|
||||
let grip = if floating {
|
||||
let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE));
|
||||
let xr = ui.interact(x, egui::Id::new(("tool-flyout-close", key)), Sense::click());
|
||||
icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text });
|
||||
out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked();
|
||||
egui::Rect::from_min_max(bar.min + vec2(0.0, CLOSE), bar.max)
|
||||
} else {
|
||||
bar
|
||||
};
|
||||
let resp = ui.interact(grip, bar_id(key), Sense::drag());
|
||||
let half = (grip.height() / 2.0 - 3.0).clamp(0.0, 9.0);
|
||||
for k in 0..3 {
|
||||
let x = grip.center().x - 2.0 + k as f32 * 2.0;
|
||||
ui.painter().line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], Stroke::new(0.6, t.text_disabled));
|
||||
}
|
||||
let resp = if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
resp
|
||||
} else {
|
||||
let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") };
|
||||
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip)
|
||||
};
|
||||
out.bar = Some(resp);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked.
|
||||
fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> {
|
||||
let mut chosen = None;
|
||||
let w = 250.0;
|
||||
for id in tools {
|
||||
let Some(tool) = tool_info(id) else { continue };
|
||||
let is_active = active == tool.id;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
if is_active {
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text);
|
||||
}
|
||||
let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon);
|
||||
let color = if is_active { t.flyout_active } else { t.text_strong };
|
||||
ui.painter().text(r.left_center() + vec2(52.0, 0.0), egui::Align2::LEFT_CENTER, tl!(tool.label), egui::FontId::proportional(13.0), color);
|
||||
if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) {
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(18.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
format!("({sc})"),
|
||||
egui::FontId::proportional(13.0),
|
||||
color,
|
||||
);
|
||||
}
|
||||
if resp.clicked() {
|
||||
chosen = Some(tool.id);
|
||||
}
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
/// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag
|
||||
/// that tears the flyout off carries on moving it).
|
||||
fn bar_id(key: &str) -> egui::Id {
|
||||
egui::Id::new(("tool-flyout-bar", key))
|
||||
}
|
||||
|
||||
/// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged.
|
||||
fn grab_id(key: &str) -> egui::Id {
|
||||
bar_id(key).with("grab")
|
||||
}
|
||||
|
||||
/// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the
|
||||
/// tool clicked.
|
||||
fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> {
|
||||
let mut chosen = None;
|
||||
for tool in tools.iter().filter_map(|id| tool_info(id)) {
|
||||
let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click());
|
||||
let well = r.shrink(1.0);
|
||||
if active == tool.id {
|
||||
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
|
||||
}
|
||||
let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon });
|
||||
if resp.on_hover_text(tip(tool)).clicked() {
|
||||
chosen = Some(tool.id);
|
||||
}
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
/// The width of a floating flyout's strip of `tools`, its bar included.
|
||||
fn strip_width(tools: &[String]) -> f32 {
|
||||
tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * STRIP_BUTTON.x + BAR
|
||||
}
|
||||
|
||||
/// How far the pointer has moved since the press that's down, if one is.
|
||||
fn press_travel(ctx: &egui::Context) -> Option<egui::Vec2> {
|
||||
ctx.input(|i| i.pointer.press_origin().zip(i.pointer.interact_pos()).map(|(a, b)| b - a))
|
||||
}
|
||||
|
||||
fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if app.ui.flyout.is_none() {
|
||||
return;
|
||||
@@ -372,54 +571,33 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let anchor: egui::Rect =
|
||||
ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0)));
|
||||
let t = Tokens::get(ctx);
|
||||
let mut chosen = None;
|
||||
let active = app.session.tool_id();
|
||||
let mut input = FlyoutInput::default();
|
||||
let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.input_border))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) })
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
let w = 250.0;
|
||||
for id in &tools {
|
||||
let Some(tool) = tool_info(id) else { continue };
|
||||
let active = app.session.tool_id() == tool.id;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
if active {
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text);
|
||||
}
|
||||
let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon);
|
||||
let color = if active { t.flyout_active } else { t.text_strong };
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(52.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
tl!(tool.label),
|
||||
egui::FontId::proportional(13.0),
|
||||
color,
|
||||
);
|
||||
if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) {
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(18.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
format!("({sc})"),
|
||||
egui::FontId::proportional(13.0),
|
||||
color,
|
||||
);
|
||||
}
|
||||
if resp.clicked() {
|
||||
chosen = Some(tool.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false));
|
||||
},
|
||||
);
|
||||
if let Some(id) = chosen {
|
||||
// Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the
|
||||
// end of the long press that opened the flyout): it floats where the pointer is.
|
||||
let at = ctx.input(|i| i.pointer.interact_pos());
|
||||
let released_on_bar = input.bar.as_ref().is_some_and(|b| ctx.input(|i| i.pointer.primary_released()) && at.is_some_and(|p| b.rect.contains(p)));
|
||||
let dragged_off = input.bar.as_ref().is_some_and(egui::Response::dragged) && press_travel(ctx).is_some_and(|d| d.length() > TEAR);
|
||||
if released_on_bar || dragged_off {
|
||||
// The strip is far narrower than the flyout: it floats with its bar under the pointer.
|
||||
let at = at.unwrap_or(resp.response.rect.right_center());
|
||||
// (Past the frame's 1 pt border.)
|
||||
let grab = vec2(1.0 + strip_width(&tools) - BAR / 2.0, 1.0 + CLOSE + 4.0);
|
||||
let p = at - grab;
|
||||
if let Some(key) = tools.first() {
|
||||
ctx.data_mut(|m| m.insert_temp(grab_id(key), grab));
|
||||
}
|
||||
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] });
|
||||
app.ui.flyout = None;
|
||||
return;
|
||||
}
|
||||
if let Some(id) = input.chosen {
|
||||
app.select_tool(id);
|
||||
} else if resp.response.clicked_elsewhere() && !ctx.input(|i| i.pointer.interact_pos().is_some_and(|p| anchor.contains(p))) {
|
||||
// A click on the flyout's own tool button (the right-click that opened it, or the end of a
|
||||
@@ -429,6 +607,61 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let _ = theme::semibold;
|
||||
}
|
||||
|
||||
/// The flyouts torn off the toolbar, each floating where its bar was dragged; its × puts it back.
|
||||
fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if app.ui.floating_flyouts.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
let now = ctx.input(|i| i.time);
|
||||
let active = app.session.tool_id();
|
||||
let mut chosen = None;
|
||||
let mut closed = None;
|
||||
for (k, f) in app.ui.floating_flyouts.iter_mut().enumerate() {
|
||||
let Some(key) = f.tools.first() else { continue };
|
||||
let id = floating_area(key);
|
||||
let since = ctx.data(|d| d.get_temp::<f64>(id)).map(|t0| now - t0);
|
||||
let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s));
|
||||
if let Some(s) = since.filter(|_| flash) {
|
||||
ctx.request_repaint_after_secs((FLASH - s) as f32);
|
||||
}
|
||||
// Kept on screen (a smaller window, or a position saved on a bigger one).
|
||||
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size());
|
||||
let [x, y] = f.pos;
|
||||
let (x, y) = if x.is_finite() && y.is_finite() { (x, y) } else { (screen.left() + 60.0, screen.top() + 100.0) };
|
||||
let pos = pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top()));
|
||||
let mut input = FlyoutInput::default();
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
|
||||
flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true));
|
||||
});
|
||||
// Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than
|
||||
// each frame's motion, which a just torn-off flyout's first, unseen frame would drop).
|
||||
let grab = grab_id(key);
|
||||
if ctx.is_being_dragged(bar_id(key))
|
||||
&& let Some(at) = ctx.input(|i| i.pointer.interact_pos())
|
||||
{
|
||||
let off = ctx.data_mut(|m| *m.get_temp_mut_or_insert_with(grab, || at - pos));
|
||||
let p = at - off;
|
||||
if p.x.is_finite() && p.y.is_finite() {
|
||||
f.pos = [p.x, p.y];
|
||||
}
|
||||
} else if !ctx.input(|i| i.pointer.any_down()) {
|
||||
ctx.data_mut(|m| m.remove::<egui::Vec2>(grab));
|
||||
}
|
||||
if input.close {
|
||||
closed = Some(k);
|
||||
}
|
||||
chosen = chosen.or(input.chosen);
|
||||
}
|
||||
if let Some(k) = closed.filter(|k| *k < app.ui.floating_flyouts.len()) {
|
||||
app.ui.floating_flyouts.remove(k);
|
||||
}
|
||||
if let Some(id) = chosen {
|
||||
app.select_tool(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Puppet Warp tool's Control bar: Expand (how far the mesh reaches past the art), Show Mesh
|
||||
/// and Select All Pins.
|
||||
fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
@@ -734,4 +967,227 @@ pub(crate) mod tests {
|
||||
assert_eq!(app.ui.flyout, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A press at `from`, dragged through `to` (one frame each), then released.
|
||||
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: &[Pos2]) {
|
||||
let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, time, vec![Event::PointerMoved(from), b(from, true)]);
|
||||
for (k, p) in to.iter().enumerate() {
|
||||
frame(app, ctx, time + 0.05 * (k + 1) as f64, vec![Event::PointerMoved(*p)]);
|
||||
}
|
||||
let end = to.last().copied().unwrap_or(from);
|
||||
frame(app, ctx, time + 0.05 * (to.len() + 1) as f64, vec![b(end, false)]);
|
||||
frame(app, ctx, time + 0.05 * (to.len() + 2) as f64, vec![]);
|
||||
}
|
||||
|
||||
/// A click at `at`.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2, button: PointerButton) {
|
||||
let b = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, time, vec![Event::PointerMoved(at), b(true)]);
|
||||
frame(app, ctx, time + 0.05, vec![b(false)]);
|
||||
frame(app, ctx, time + 0.1, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flyout_dragged_by_its_bar_floats_until_its_close_box_puts_it_back() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let at = shapes_button(&mut app, &ctx).center();
|
||||
click(&mut app, &ctx, 1.0, at, PointerButton::Secondary);
|
||||
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the flyout is shown");
|
||||
// Its grab bar runs down its right side: dragging it tears the flyout off.
|
||||
let bar = pos2(menu.right() - BAR / 2.0, menu.center().y);
|
||||
drag(&mut app, &ctx, 2.0, bar, &[bar + vec2(20.0, 0.0), bar + vec2(60.0, 40.0)]);
|
||||
assert_eq!(app.ui.flyout, None, "the flyout became the floating one");
|
||||
let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one floating flyout: {:?}", app.ui.floating_flyouts) };
|
||||
assert_eq!(f.tools.first().map(String::as_str), Some("rectangle"));
|
||||
let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown");
|
||||
// A strip of its nine tools' buttons, its bar under the pointer.
|
||||
let drop = bar + vec2(60.0, 40.0);
|
||||
assert!(float.height() < 40.0 && float.width() > 9.0 * STRIP_BUTTON.x, "a one-row strip: {float:?}");
|
||||
assert!(
|
||||
(float.right() - 1.0 - BAR / 2.0 - drop.x).abs() < 1.0 && float.y_range().contains(drop.y),
|
||||
"its bar is under the pointer: {float:?}"
|
||||
);
|
||||
// The same bar moves it.
|
||||
let bar = pos2(float.right() - 1.0 - BAR / 2.0, float.bottom() - 6.0);
|
||||
drag(&mut app, &ctx, 3.0, bar, &[bar + vec2(0.0, 20.0), bar + vec2(-10.0, 100.0)]);
|
||||
let moved = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap();
|
||||
assert!((moved.min - (float.min + vec2(-10.0, 100.0))).length() < 2.0, "moved to {moved:?} from {float:?}");
|
||||
// Picking a tool in it leaves it open.
|
||||
click(&mut app, &ctx, 4.0, pos2(moved.left() + STRIP_BUTTON.x * 1.5, moved.center().y), PointerButton::Primary);
|
||||
assert_eq!((app.session.tool_id(), app.ui.floating_flyouts.len()), ("roundedRectangle", 1));
|
||||
// While it floats, the presses that open the group's flyout raise it instead.
|
||||
click(&mut app, &ctx, 5.0, at, PointerButton::Secondary);
|
||||
assert_eq!(app.ui.flyout, None, "no flyout beside the floating one");
|
||||
assert!(ctx.data(|d| d.get_temp::<f64>(floating_area("rectangle"))).is_some(), "the floating flyout was raised");
|
||||
let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 6.0, vec![Event::PointerMoved(at), p(true)]);
|
||||
frame(&mut app, &ctx, 6.0 + LONG_PRESS + 0.01, vec![]);
|
||||
frame(&mut app, &ctx, 6.5, vec![p(false)]);
|
||||
assert_eq!((app.ui.flyout, app.session.tool_id()), (None, "roundedRectangle"), "a long press neither opens the flyout nor picks the tool");
|
||||
// The × at the top of the bar puts it back: the group opens as a flyout again.
|
||||
click(&mut app, &ctx, 7.0, pos2(moved.right() - BAR / 2.0, moved.top() + CLOSE / 2.0), PointerButton::Primary);
|
||||
assert!(app.ui.floating_flyouts.is_empty());
|
||||
click(&mut app, &ctx, 8.0, at, PointerButton::Secondary);
|
||||
assert!(app.ui.flyout.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_flyouts_bar_tears_it_off() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let at = shapes_button(&mut app, &ctx).center();
|
||||
click(&mut app, &ctx, 1.0, at, PointerButton::Secondary);
|
||||
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).unwrap();
|
||||
let bar = pos2(menu.right() - BAR / 2.0, menu.center().y);
|
||||
click(&mut app, &ctx, 2.0, bar, PointerButton::Primary);
|
||||
frame(&mut app, &ctx, 2.2, vec![]);
|
||||
assert_eq!(app.ui.flyout, None);
|
||||
assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::<Vec<_>>(), ["rectangle"]);
|
||||
let float = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).expect("the floating flyout is shown");
|
||||
assert!(float.contains(bar), "its bar is under the pointer: {float:?}");
|
||||
assert_eq!(app.session.tool_id(), "selection", "no tool picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_long_press_over_the_flyouts_bar_tears_it_off() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let at = shapes_button(&mut app, &ctx).center();
|
||||
let b = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(at, true)]);
|
||||
frame(&mut app, &ctx, 1.0 + LONG_PRESS + 0.01, vec![]);
|
||||
frame(&mut app, &ctx, 1.45, vec![]);
|
||||
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the long press opens the flyout");
|
||||
let bar = pos2(menu.right() - BAR / 2.0, menu.center().y);
|
||||
frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(bar)]);
|
||||
frame(&mut app, &ctx, 1.55, vec![b(bar, false)]);
|
||||
frame(&mut app, &ctx, 1.6, vec![]);
|
||||
assert_eq!((app.ui.flyout, app.ui.floating_flyouts.len(), app.session.tool_id()), (None, 1, "selection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_float_tools_floats_and_puts_back_a_group() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let check = |app: &VectorcraftApp| crate::menus::checked(app, "window.floatTools", &json!({"tool": "star"}));
|
||||
assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(true));
|
||||
let [f] = app.ui.floating_flyouts.as_slice() else { panic!("one strip: {:?}", app.ui.floating_flyouts) };
|
||||
assert_eq!(f.tools.first().map(String::as_str), Some("rectangle"), "the Basic toolbar's Shapes group");
|
||||
assert_eq!(check(&app), Some(true));
|
||||
// Already floating: stays where it is.
|
||||
app.ui.floating_flyouts[0].pos = [300.0, 400.0];
|
||||
assert_eq!(app.run("window.floatTools", json!({"tool": "rectangle", "floating": true})).unwrap(), json!(true));
|
||||
assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.pos).collect::<Vec<_>>(), [[300.0, 400.0]]);
|
||||
// Omitted toggles it back.
|
||||
assert_eq!(app.run("window.floatTools", json!({"tool": "star"})).unwrap(), json!(false));
|
||||
assert!(app.ui.floating_flyouts.is_empty() && check(&app) == Some(false));
|
||||
assert_eq!(app.run("window.floatTools", json!({"tool": "pen", "floating": false})).unwrap(), json!(false));
|
||||
// A tool alone in its slot, an unknown tool, a bad flag.
|
||||
assert!(app.run("window.floatTools", json!({"tool": "ellipse"})).is_err());
|
||||
assert!(app.run("window.floatTools", json!({"tool": "nope"})).is_err());
|
||||
assert!(app.run("window.floatTools", json!({})).is_err());
|
||||
assert!(app.run("window.floatTools", json!({"tool": "pen", "floating": "yes"})).is_err());
|
||||
assert!(app.ui.floating_flyouts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspaces_keep_floating_flyouts_and_reset_puts_them_back() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("window.floatTools", json!({"tool": "pen"})).unwrap();
|
||||
app.run("window.workspace.new", json!({"name": "Strips"})).unwrap();
|
||||
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
|
||||
assert!(app.ui.floating_flyouts.is_empty(), "the built-in workspaces float none");
|
||||
app.run("window.workspace", json!({"name": "Strips"})).unwrap();
|
||||
assert_eq!(app.ui.floating_flyouts.iter().map(|f| f.tools[0].as_str()).collect::<Vec<_>>(), ["pen"]);
|
||||
// Workspaces saved before floating strips load without any.
|
||||
let mut old = serde_json::to_value(crate::workspaces::Workspace::default()).unwrap();
|
||||
old.as_object_mut().unwrap().remove("floatingFlyouts");
|
||||
assert!(serde_json::from_value::<crate::workspaces::Workspace>(old).unwrap().floating_flyouts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_floating_flyouts_keep_known_tools_one_strip_per_group() {
|
||||
let f = |tools: &[&str]| crate::state::FloatingFlyout { tools: tools.iter().map(|s| s.to_string()).collect(), pos: [0.0, 0.0] };
|
||||
let ui = crate::state::UiState {
|
||||
floating_flyouts: vec![f(&["pen", "nope", "addAnchor"]), f(&["pen", "addAnchor"]), f(&["nope"]), f(&[])],
|
||||
..Default::default()
|
||||
};
|
||||
let ui = ui.sanitized();
|
||||
assert_eq!(ui.floating_flyouts.iter().map(|f| f.tools.join(",")).collect::<Vec<_>>(), ["pen,addAnchor"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_flyouts_survive_saved_preferences_and_stay_on_screen() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools: vec!["pen".into(), "addAnchor".into()], pos: [5000.0, 10.0] });
|
||||
let saved = serde_json::to_value(&app.ui).unwrap();
|
||||
let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap();
|
||||
assert_eq!(back.floating_flyouts.len(), 1);
|
||||
// Preferences saved before floating flyouts load without any.
|
||||
let mut old = saved;
|
||||
old.as_object_mut().unwrap().remove("floating_flyouts");
|
||||
assert!(serde_json::from_value::<crate::state::UiState>(old).unwrap().floating_flyouts.is_empty());
|
||||
let ctx = egui::Context::default();
|
||||
shapes_button(&mut app, &ctx);
|
||||
frame(&mut app, &ctx, 1.0, vec![]);
|
||||
let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap();
|
||||
assert!(r.right() <= 400.5 && r.left() >= 0.0 && r.top() >= 0.0, "on screen: {r:?}");
|
||||
// A position that isn't a number puts it near the toolbar.
|
||||
app.ui.floating_flyouts[0].pos = [f32::NAN, f32::INFINITY];
|
||||
frame(&mut app, &ctx, 2.0, vec![]);
|
||||
let r = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap();
|
||||
assert!(r.min.x.is_finite() && r.min.y.is_finite() && r.left() >= 0.0, "on screen: {r:?}");
|
||||
}
|
||||
|
||||
/// Hover `at` for two seconds, coming from elsewhere: the texts painted meanwhile.
|
||||
fn hovered_texts(app: &mut VectorcraftApp, ctx: &egui::Context, t0: f64, at: Pos2) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut shown = vec![];
|
||||
for i in 0..20 {
|
||||
let events = match i {
|
||||
0 => vec![Event::PointerGone],
|
||||
1 => vec![Event::PointerMoved(at)],
|
||||
_ => vec![],
|
||||
};
|
||||
let time = t0 + f64::from(i) * 0.1;
|
||||
let raw = egui::RawInput {
|
||||
events,
|
||||
time: Some(time),
|
||||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
crate::prefs_dialog::apply_runtime(app, ui.ctx());
|
||||
show(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
}
|
||||
shown
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floating_flyouts_buttons_have_tool_tips_when_tool_tips_are_on() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let tools: Vec<String> = ["rectangle", "roundedRectangle", "star"].map(String::from).to_vec();
|
||||
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] });
|
||||
let ctx = egui::Context::default();
|
||||
shapes_button(&mut app, &ctx);
|
||||
frame(&mut app, &ctx, 1.0, vec![]);
|
||||
let strip = ctx.memory(|m| m.area_rect(floating_area("rectangle"))).unwrap();
|
||||
let star = pos2(strip.left() + STRIP_BUTTON.x * 2.5, strip.center().y);
|
||||
let tip = tl!("Star Tool").to_string();
|
||||
assert!(hovered_texts(&mut app, &ctx, 10.0, star).contains(&tip), "on by default");
|
||||
app.session.prefs.show_tool_tips = false;
|
||||
assert!(!hovered_texts(&mut app, &ctx, 20.0, star).contains(&tip), "off: no tool tip");
|
||||
app.session.prefs.show_tool_tips = true;
|
||||
assert!(hovered_texts(&mut app, &ctx, 30.0, star).contains(&tip), "on again");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::atomic::Ordering;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::{Dialog, DockTab, UiState};
|
||||
use crate::state::{Dialog, DockTab, FloatingFlyout, UiState};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct Workspace {
|
||||
pub dock_collapsed: bool,
|
||||
pub open_panel: Option<String>,
|
||||
pub status_bar: bool,
|
||||
/// Tool groups torn off the toolbar into floating strips (built-in workspaces: none).
|
||||
pub floating_flyouts: Vec<FloatingFlyout>,
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
@@ -120,6 +122,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
|
||||
dock_collapsed: ui.dock_collapsed,
|
||||
open_panel: ui.open_panel.clone(),
|
||||
status_bar: ui.status_bar,
|
||||
floating_flyouts: ui.floating_flyouts.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +139,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
|
||||
ui.dock_collapsed = w.dock_collapsed;
|
||||
ui.open_panel = w.open_panel.clone();
|
||||
ui.status_bar = w.status_bar;
|
||||
ui.floating_flyouts = w.floating_flyouts.clone();
|
||||
ui.flyout = None;
|
||||
ui.workspace = w.name.clone();
|
||||
}
|
||||
|
||||
@@ -192,6 +192,14 @@ pop the panel out next to the column like the other icon panels (`ui.dock_collap
|
||||
`ui.inspect`); expanding with one popped out shows its tab. The state is saved with the preferences and in user
|
||||
workspaces; the built-in workspaces expand the dock.
|
||||
|
||||
Floating tool groups: dragging or clicking the tear-off bar down a tool group's flyout (or releasing the long press
|
||||
that opened it over the bar) floats the group as a strip of tool buttons, moved by the same bar and put back in the
|
||||
toolbar by the × at its top; `window.floatTools {tool, floating?}` does the same for the group of the current
|
||||
layout holding `tool` (omitted toggles; a tool alone in its slot is an error). While a group floats, the presses
|
||||
that open its flyout raise the strip instead. The strips (`ui.floating_flyouts` in `ui.inspect`: the group's tools
|
||||
and the strip's top-left corner) are saved with the preferences and in user workspaces; the built-in workspaces
|
||||
float none.
|
||||
|
||||
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
|
||||
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
|
||||
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,
|
||||
|
||||
Reference in New Issue
Block a user