mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Merge pull request #4113 from storytold/codex/egui-037-vectorcraft-20261023
Migrate VectorCraft input, text and capture to egui 0.37
This commit is contained in:
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
|
||||
@@ -29,5 +29,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
run-package-check: true
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
|
||||
@@ -12,6 +12,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts |
|
||||
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
|
||||
|
||||
Generated
+1380
-151
File diff suppressed because it is too large
Load Diff
+31
-8
@@ -12,13 +12,21 @@ repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
# Kept at the egui-0.37 port revision on purpose: 25da contains 4f85efb's bounded print
|
||||
# parsing and 2bbef53's printer names; d991bf67 (VectorCraft main's pin) lacks both. This is a
|
||||
# targeted security/UI bump, not an all-libs flatten. Re-check if main backports them.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
# egui 0.37 port. 25da has craft_ui::capture and the old docking surface
|
||||
# (DockContent/show_customized; Floating {panels, active, rect}) that VectorCraft's
|
||||
# panel_docking.rs uses. Do not bump this to the new-docking L revision without porting that file.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
|
||||
# The egui 0.37 port of craft-keymap. 25da's core keymap API matches 2eca9b3a; only its
|
||||
# `ui` feature and tests differ, and VectorCraft enables only `egui`.
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
@@ -102,10 +110,10 @@ tiff = "0.11"
|
||||
zune-jpeg = "0.5"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
clipboard-win = "5.4"
|
||||
@@ -146,3 +154,18 @@ hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -123,3 +123,21 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
|
||||
emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is
|
||||
included through monochrome_emoji_fonts. Their upstream project is
|
||||
https://github.com/jslegers/emoji-icon-font; both use the same exact copyright
|
||||
and permission notice below.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -106,6 +106,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
canvas,
|
||||
web_options(),
|
||||
Box::new(move |cc| {
|
||||
VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
let ctx = &cc.egui_ctx;
|
||||
crate::fonts::spawn_background_fonts(ctx.clone());
|
||||
let loss = GraphicsLoss::default();
|
||||
@@ -365,48 +366,52 @@ impl eframe::App for WebShell {
|
||||
self.restart(&why);
|
||||
}
|
||||
let Some(app) = &mut self.app else { return };
|
||||
let pasted = self.host.pasted.borrow_mut().flavour.take();
|
||||
if let Some((f, held)) = pasted {
|
||||
app.paste_from_host(ctx, f, held);
|
||||
}
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
if !dropped.is_empty() {
|
||||
let at = self.host.drag.take();
|
||||
let z = ctx.zoom_factor();
|
||||
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
|
||||
for f in dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
let target = app.drop_target(&name, pos, shift);
|
||||
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
match target {
|
||||
DropTarget::Place(d) => {
|
||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
|
||||
}
|
||||
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
app.tick(ctx);
|
||||
crate::api::poll_replies();
|
||||
app.finish_ime_logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
raw.events.extend(self.host.pointers.borrow_mut().take());
|
||||
if let Some(app) = &mut self.app {
|
||||
app.raw_input_hook(raw);
|
||||
app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Some(app) = &mut self.app {
|
||||
// DOM paste and drop are host input: read them in the UI phase, once per pass, not in
|
||||
// `logic` where a hidden tab replays the last shown frame's input.
|
||||
let pasted = self.host.pasted.borrow_mut().flavour.take();
|
||||
if let Some((f, held)) = pasted {
|
||||
app.paste_from_host(&ctx, f, held);
|
||||
}
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
if !dropped.is_empty() {
|
||||
let at = self.host.drag.take();
|
||||
let z = ctx.zoom_factor();
|
||||
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
|
||||
for f in dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
let target = app.drop_target(&name, pos, shift);
|
||||
let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
match target {
|
||||
DropTarget::Place(d) => {
|
||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
|
||||
}
|
||||
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
|
||||
}
|
||||
repaint.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
|
||||
@@ -23,7 +23,7 @@ vectorcraft-text = { workspace = true }
|
||||
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
|
||||
# so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217.
|
||||
craft-window = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["clipboard", "default_fonts", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -89,8 +89,9 @@ impl eframe::App for App {
|
||||
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
file_access::unconfined(|| self.logic_frame(ctx, frame));
|
||||
self.app.finish_ime_logic(ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// winit gives the window its caption back when it changes the window's flags (maximize,
|
||||
// restore, …): take it off again before the frame is laid out (#1758).
|
||||
if let Some(window_frame) = self.window_frame.as_mut() {
|
||||
@@ -103,7 +104,7 @@ impl eframe::App for App {
|
||||
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
windows_stylus::raw_input_hook(raw);
|
||||
file_access::unconfined(|| self.app.raw_input_hook(raw));
|
||||
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
|
||||
}
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -164,7 +165,7 @@ impl App {
|
||||
}
|
||||
open_files(&mut self.app, files);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
self.app.tick(ctx);
|
||||
if self.pending_maximize {
|
||||
if let Some(w) = &self.winit_window {
|
||||
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
|
||||
@@ -1014,6 +1015,7 @@ fn main() -> std::process::ExitCode {
|
||||
run(
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, SurroundingUnit, ToolKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -184,3 +184,137 @@ fn discard_empty_command() {
|
||||
assert_eq!(undo_labels(&s), ["Type", "Rectangle"]);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// An input method that replaces text around its composition (macOS press-and-hold/dictation,
|
||||
/// Wayland text-input-v3): `DeleteSurroundingBytes` counts exact UTF-8 bytes and shares the
|
||||
/// commit's typing session, so the replacement is one undo step. Never splits a UTF-8 sequence.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_bytes_replaces_text_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Héllo", v).unwrap();
|
||||
let steps = undo_labels(&s).len();
|
||||
// "llo" is three UTF-8 bytes before the caret; the commit inserts y in the same session.
|
||||
s.tool_ime_delete_surrounding(3, 0, SurroundingUnit::Bytes, v).unwrap();
|
||||
s.tool_text("y", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Héy"]);
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
|
||||
}
|
||||
|
||||
/// Character counts (upstream `DeleteSurrounding`) delete whole scalar values, and a byte count
|
||||
/// that lands inside a multi-byte character deletes fewer bytes rather than splitting it.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_counts_characters_and_respects_utf8_boundaries() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("aé", v).unwrap();
|
||||
// A byte count starting inside é (byte 2 of the text) moves forward to the boundary: nothing
|
||||
// is deleted rather than splitting the character, and the commit still goes in.
|
||||
s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Bytes, v).unwrap();
|
||||
s.tool_text("!", v).unwrap();
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["aé!"]);
|
||||
// One character before the caret removes the whole "!"; é itself is one character too.
|
||||
s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Chars, v).unwrap();
|
||||
s.tool_text("z", v).unwrap();
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["aéz"]);
|
||||
}
|
||||
|
||||
/// A selection is what an IME's surrounding bytes replace, and bytes after it count too; the
|
||||
/// commit shares the one typing step.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_replaces_a_selection_and_after_bytes() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Hello", v).unwrap();
|
||||
// Caret before "lo"; Shift+Left selects the first "l".
|
||||
key(&mut s, ToolKey::Left);
|
||||
key(&mut s, ToolKey::Left);
|
||||
s.tool_key(ToolKey::Left, Mods { shift: true, ..Mods::default() }, v).unwrap();
|
||||
let steps = undo_labels(&s).len();
|
||||
// One byte before the selection ("e") and one after it ("o") go with it.
|
||||
s.tool_ime_delete_surrounding(1, 1, SurroundingUnit::Bytes, v).unwrap();
|
||||
s.tool_text("y", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hyo"]);
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
|
||||
}
|
||||
|
||||
/// A pre-edit in the way is replaced by the IME's surrounding deletion, then the commit lands in
|
||||
/// the same typing step.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_replaces_the_preedit_and_commit_in_one_step() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("ab", v).unwrap();
|
||||
s.tool_preedit("xy", None, v).unwrap();
|
||||
let steps = undo_labels(&s).len();
|
||||
// Two bytes before the pre-edit: "ab" and the marked "xy" all go, then é is committed.
|
||||
s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap();
|
||||
s.tool_text("é", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["é"]);
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
|
||||
}
|
||||
|
||||
/// RTL text follows logical UTF-8 byte offsets too: the byte count before the pre-edit deletes
|
||||
/// the whole preceding Hebrew character (two bytes), never splitting it.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_uses_logical_bytes_for_rtl_text() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("שלום", v).unwrap();
|
||||
s.tool_preedit("xy", None, v).unwrap();
|
||||
s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap();
|
||||
s.tool_text("א", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["שלוא"]);
|
||||
}
|
||||
|
||||
/// The Type tool's IME owner is stable across ordinary edits in one document and changes with the
|
||||
/// document (a new tab/file has a new process-unique uid).
|
||||
#[test]
|
||||
fn ime_target_survives_edits_and_changes_with_the_document() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
let first = s.tool_ime_target(v).expect("editing has a target");
|
||||
s.tool_text("A", v).unwrap();
|
||||
assert_eq!(s.tool_ime_target(v), Some(first), "ordinary typing keeps the owner");
|
||||
key(&mut s, ToolKey::Left);
|
||||
assert_eq!(s.tool_ime_target(v), Some(first), "caret moves keep the owner");
|
||||
key(&mut s, ToolKey::Escape);
|
||||
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
let second = s.tool_ime_target(v).expect("the new document's type has a target");
|
||||
assert_ne!(first.0, second.0, "a document replacement is a new IME owner");
|
||||
}
|
||||
|
||||
/// The outgoing surrounding-text source excludes the pre-edit and reports UTF-8 byte offsets.
|
||||
#[test]
|
||||
fn ime_surrounding_excludes_the_preedit_and_remaps_offsets() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Hé", v).unwrap();
|
||||
s.tool_preedit("xy", None, v).unwrap();
|
||||
let vectorcraft_tools::ImeSurrounding::Available { text, cursor, anchor } = s.tool_ime_surrounding(4000, v) else {
|
||||
panic!("type editing has a context");
|
||||
};
|
||||
assert_eq!(text, "Hé");
|
||||
assert_eq!((cursor, anchor), (3, 3), "the caret sits after the committed text, before the pre-edit");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use vectorcraft_doc::Selection;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::distort::perspective::widget::WidgetPlace;
|
||||
use vectorcraft_tools::draw2::FEEDBACK;
|
||||
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
|
||||
use vectorcraft_tools::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
|
||||
|
||||
use crate::{EngineError, Prefs, Result, Session};
|
||||
|
||||
@@ -429,6 +429,20 @@ impl Session {
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering,
|
||||
/// newline normalization or document formatting such as Typographer's Quotes)? An IME commit
|
||||
/// the application rewrites is an application edit, not an accepted IME insertion.
|
||||
pub fn tool_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool {
|
||||
self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text))
|
||||
}
|
||||
|
||||
/// Does the active tool's open typing session still match the document? An IME commit after
|
||||
/// an external edit starts a fresh session (an application-side reset), so its receipt must
|
||||
/// not be accepted as an unchanged IME insertion.
|
||||
pub fn tool_ime_session_matches(&mut self, view: ViewInfo) -> bool {
|
||||
self.with_tool_cx(view, |t, cx| t.ime_session_matches_document(cx))
|
||||
}
|
||||
|
||||
pub fn tool_wants_text(&self) -> bool {
|
||||
self.tool.wants_text()
|
||||
}
|
||||
@@ -441,6 +455,37 @@ impl Session {
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// An IME replaces text around its composition before committing: delete `before`/`after`
|
||||
/// units around the selection or pre-edit. The selection/pre-edit itself is preserved (the
|
||||
/// following commit replaces it), and the commit shares the typing session, so the replacement
|
||||
/// is one undo step.
|
||||
pub fn tool_ime_delete_surrounding(
|
||||
&mut self,
|
||||
before: usize,
|
||||
after: usize,
|
||||
unit: vectorcraft_tools::SurroundingUnit,
|
||||
view: ViewInfo,
|
||||
) -> Result<Vec<UiRequest>> {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.ime_delete_surrounding(cx, before, after, unit));
|
||||
self.take_tool_panic()?;
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// The active tool's stable IME owner `(document uid, target id, replacement epoch)`: the
|
||||
/// document uid changes on document replacement, the epoch when the same target is started
|
||||
/// afresh. Ordinary edits and caret moves leave both unchanged.
|
||||
pub fn tool_ime_target(&mut self, view: ViewInfo) -> Option<(u64, u64, u64)> {
|
||||
self.with_tool_cx(view, |t, cx| t.ime_target(cx))
|
||||
}
|
||||
|
||||
/// The committed logical text around the IME cursor (UTF-8 byte offsets), with any pre-edit
|
||||
/// removed and at most `max_bytes` bytes copied: the app's outgoing surrounding-text producer
|
||||
/// (egui E21b) reads it. An explicit [`ImeSurrounding::Unavailable`] carries the narrow reason
|
||||
/// (no editor, invalid offsets, or a selection wider than the limit).
|
||||
pub fn tool_ime_surrounding(&mut self, max_bytes: usize, view: ViewInfo) -> ImeSurrounding {
|
||||
self.with_tool_cx(view, |t, cx| t.ime_surrounding(cx, max_bytes))
|
||||
}
|
||||
|
||||
/// Is the active tool showing uncommitted IME text?
|
||||
pub fn tool_composing(&self) -> bool {
|
||||
self.tool.composing()
|
||||
|
||||
@@ -770,6 +770,31 @@ impl Cursor {
|
||||
}
|
||||
}
|
||||
|
||||
/// What an IME's "delete the text around the cursor" counts: UTF-8 bytes (winit's raw
|
||||
/// `DeleteSurroundingBytes`) or Unicode scalar values (egui's `DeleteSurrounding`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SurroundingUnit {
|
||||
Bytes,
|
||||
Chars,
|
||||
}
|
||||
|
||||
/// The committed logical text around an IME cursor, or the documented reason it is unavailable.
|
||||
///
|
||||
/// This type stays free of any UI dependency: offsets are UTF-8 bytes into `text`, `cursor` is the
|
||||
/// primary selection end and `anchor` the other (so `cursor > anchor` is a reversed selection).
|
||||
/// `text` is already bounded to the caller's request and has the pre-edit removed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImeSurrounding {
|
||||
Available { text: String, cursor: usize, anchor: usize },
|
||||
Unavailable(&'static str),
|
||||
}
|
||||
|
||||
impl Default for ImeSurrounding {
|
||||
fn default() -> Self {
|
||||
Self::Unavailable("no document is active")
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool state machine.
|
||||
pub trait Tool: Send {
|
||||
fn id(&self) -> &'static str;
|
||||
@@ -809,6 +834,19 @@ pub trait Tool: Send {
|
||||
fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// Would [`Tool::text_input`] insert `text` exactly as given? False when it filters control
|
||||
/// characters, normalizes newlines, applies Typographer's Quotes or otherwise rewrites the
|
||||
/// insertion. An IME commit the application rewrites is an application edit: its receipt must
|
||||
/// be `Other`, never `InputMethod`.
|
||||
fn text_input_is_verbatim(&self, _cx: &ToolContext, _text: &str) -> bool {
|
||||
true
|
||||
}
|
||||
/// Does the tool's open typing session still match the document? False when an external edit
|
||||
/// replaced the text under it: the next IME commit starts a fresh session, which is an
|
||||
/// application-side reset, not an accepted IME insertion.
|
||||
fn ime_session_matches_document(&self, _cx: &ToolContext) -> bool {
|
||||
true
|
||||
}
|
||||
/// IME composition (marked text) for the tool that wants text: `text` replaces the previous
|
||||
/// marked text (or the selection); an empty `text` ends the composition. `active_chars` is the
|
||||
/// clause being converted, in characters of `text`. The committed result arrives through
|
||||
@@ -816,6 +854,27 @@ pub trait Tool: Send {
|
||||
fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option<std::ops::Range<usize>>) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// An input method replaces text around its composition: delete `before` and `after` units
|
||||
/// (bytes or characters, per [`SurroundingUnit`]) around the selection or pre-edit, in the
|
||||
/// same typing session as the commit that follows. The selection/pre-edit itself is preserved
|
||||
/// (the following commit replaces it), its direction is kept, and a unit count ending inside a
|
||||
/// UTF-8 scalar leaves that scalar whole.
|
||||
fn ime_delete_surrounding(&mut self, _cx: &ToolContext, _before: usize, _after: usize, _unit: SurroundingUnit) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// Stable IME owner data for the tool's current edit target: `(document uid, target id,
|
||||
/// replacement epoch)`. The owner survives ordinary edits and caret moves; the replacement
|
||||
/// epoch changes when the same target is started afresh (a new edit session).
|
||||
fn ime_target(&self, _cx: &ToolContext) -> Option<(u64, u64, u64)> {
|
||||
None
|
||||
}
|
||||
/// The committed logical text around the IME cursor, with UTF-8 byte offsets (`cursor` is the
|
||||
/// primary end, `anchor` the other): the source for an outgoing surrounding-text payload
|
||||
/// (egui's E21b). Pre-edit text is excluded and the offsets are remapped into the remaining
|
||||
/// text; at most `max_bytes` bytes are copied (a wider selection is explicitly unavailable).
|
||||
fn ime_surrounding(&self, _cx: &ToolContext, _max_bytes: usize) -> ImeSurrounding {
|
||||
ImeSurrounding::Unavailable("the active tool has no IME text context")
|
||||
}
|
||||
/// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is.
|
||||
fn composing(&self) -> bool {
|
||||
false
|
||||
|
||||
+480
-19
@@ -21,7 +21,7 @@
|
||||
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
@@ -30,7 +30,7 @@ use vectorcraft_geom::{BezPath, Point, Rect, Shape};
|
||||
use vectorcraft_text::{FontDb, TextLayout, edit};
|
||||
|
||||
use crate::guides::DrawSnap;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
use crate::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, SurroundingUnit, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// Which of the Type tools this is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -63,6 +63,23 @@ struct Preedit {
|
||||
/// The edited text's layout, keyed by the font database's generation and the text.
|
||||
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
|
||||
|
||||
/// Process-global Type-tool edit-session counter: the IME target's replacement epoch.
|
||||
///
|
||||
/// A per-tool counter restarts at zero when the tool is recreated (a document switch, File ›
|
||||
/// Revert), while the document keeps its uid and node ids. The same `(document, node)` would then
|
||||
/// receive an epoch a still-cached native IME context already used, violating the replacement
|
||||
/// contract that an old context can never edit the new owner. Sessions are therefore numbered for
|
||||
/// the process lifetime; the counter only stops changing after 2^64 sessions (unreachable).
|
||||
/// A mutex (not an `AtomicU64`) keeps this available on wasm targets without 64-bit atomics.
|
||||
static NEXT_EDIT_SESSION: Mutex<u64> = Mutex::new(0);
|
||||
|
||||
fn next_edit_session() -> u64 {
|
||||
let mut next = NEXT_EDIT_SESSION.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let session = next.checked_add(1).unwrap_or(u64::MAX);
|
||||
*next = session;
|
||||
session
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TypeTool {
|
||||
vertical: bool,
|
||||
@@ -71,6 +88,11 @@ pub struct TypeTool {
|
||||
prev_tool: Option<String>,
|
||||
/// Text object being edited.
|
||||
editing: Option<NodeId>,
|
||||
/// Monotonic identity of this edit session (the IME target's replacement epoch): it changes
|
||||
/// whenever editing starts afresh, including on the same text object, and never on ordinary
|
||||
/// edits or caret moves. Numbered process-wide ([`NEXT_EDIT_SESSION`]), so recreating the tool
|
||||
/// (File › Revert, a document switch) cannot reuse an epoch of the same document and node.
|
||||
edit_session: u64,
|
||||
/// The point type a click just placed (edited now): discarded if empty when editing ends.
|
||||
fresh: Option<NodeId>,
|
||||
/// Caret and selection anchor (byte offsets into the plain text).
|
||||
@@ -80,6 +102,10 @@ pub struct TypeTool {
|
||||
goal_x: Option<f64>,
|
||||
typing: Option<Typing>,
|
||||
preedit: Option<Preedit>,
|
||||
/// A command from outside the tool (`settle_interaction`, an external edit while typing) ended
|
||||
/// the typing session, while the text stays open for editing. A commit over that edit is
|
||||
/// application-origin unless an IME deletion has already begun a fresh typing session.
|
||||
typing_ended: bool,
|
||||
/// Where the button went down, and where new type goes from there (snapped to Smart Guides).
|
||||
press: Option<(Point, Point)>,
|
||||
/// The other corner of an area dragged out (snapped).
|
||||
@@ -129,16 +155,74 @@ impl TypeTool {
|
||||
}
|
||||
|
||||
fn plain(&self, cx: &ToolContext) -> String {
|
||||
let document = self.editing.and_then(|id| Self::text(cx, id));
|
||||
match &self.typing {
|
||||
// A session the document changed under is not the edited text any more: use the
|
||||
// document's own runs (word/paragraph selection must not index stale text).
|
||||
Some(ty) if document.is_some_and(|t| ty.cur != t.runs) => document.map(|t| t.plain_text()).unwrap_or_default(),
|
||||
Some(ty) => ty.cur.iter().map(|r| r.text.as_str()).collect(),
|
||||
None => self.editing.and_then(|id| Self::text(cx, id)).map(|t| t.plain_text()).unwrap_or_default(),
|
||||
None => document.map(|t| t.plain_text()).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The edited object as it currently is (with the typing session's runs).
|
||||
/// The edited plain text as borrowed run slices: the IME query copies only the bounded window
|
||||
/// it needs, never the whole document text. A typing session whose runs no longer match the
|
||||
/// document (an external edit) is not used; the document's runs are.
|
||||
fn plain_segments<'a>(&'a self, cx: &'a ToolContext) -> Option<Vec<&'a str>> {
|
||||
let id = self.editing?;
|
||||
let t = Self::text(cx, id)?;
|
||||
match &self.typing {
|
||||
Some(ty) if ty.cur == t.runs => Some(ty.cur.iter().map(|r| r.text.as_str()).collect()),
|
||||
_ => Some(t.runs.iter().map(|r| r.text.as_str()).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact text [`Tool::text_input`] would insert for `s`: control characters removed, `\r`
|
||||
/// normalized to `\n`, and Typographer's Quotes applied to a straight quote. `None` when
|
||||
/// nothing would be inserted. Shared with [`Tool::text_input_is_verbatim`] so the IME receipt
|
||||
/// and the insertion cannot disagree about what was transformed.
|
||||
fn input_text(&self, cx: &ToolContext, s: &str) -> Option<String> {
|
||||
let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's
|
||||
// opening or closing quote, by the character before it.
|
||||
let setup = &cx.doc.setup;
|
||||
if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") {
|
||||
let mut prev = self.char_before(cx, self.caret.min(self.anchor));
|
||||
return Some(setup.quotes.apply(&s, &mut prev));
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// The character just before byte `at` in the edited plain text, walked run by run (the quote
|
||||
/// direction needs one character, not a copy of the whole text).
|
||||
fn char_before(&self, cx: &ToolContext, at: usize) -> Option<char> {
|
||||
let segments = self.plain_segments(cx)?;
|
||||
let mut acc = 0usize;
|
||||
let mut prev = None;
|
||||
for segment in &segments {
|
||||
let end = acc + segment.len();
|
||||
if at <= acc {
|
||||
break;
|
||||
}
|
||||
let local = at.min(end).saturating_sub(acc);
|
||||
if let Some((_, c)) = segment.char_indices().take_while(|(i, _)| *i < local).last() {
|
||||
prev = Some(c);
|
||||
}
|
||||
acc = end;
|
||||
}
|
||||
prev
|
||||
}
|
||||
|
||||
/// The edited object as it currently is (with the typing session's runs). A session the
|
||||
/// document changed under is dropped: the document's own runs win.
|
||||
fn current(&self, cx: &ToolContext) -> Option<TextObject> {
|
||||
let mut t = Self::text(cx, self.editing?)?.clone();
|
||||
if let Some(ty) = &self.typing {
|
||||
if let Some(ty) = &self.typing
|
||||
&& ty.cur == t.runs
|
||||
{
|
||||
t.runs = ty.cur.clone();
|
||||
}
|
||||
Some(t)
|
||||
@@ -183,9 +267,11 @@ impl TypeTool {
|
||||
if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) {
|
||||
out.push(Action::Commit);
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
}
|
||||
if self.typing.is_none() {
|
||||
out.push(Action::Begin("Typing".into()));
|
||||
self.typing_ended = false;
|
||||
}
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
|
||||
let len = edit::runs_len(&ty.cur);
|
||||
@@ -219,6 +305,7 @@ impl TypeTool {
|
||||
}
|
||||
|
||||
fn start_editing(&mut self, id: NodeId, caret: usize) {
|
||||
self.edit_session = next_edit_session();
|
||||
self.editing = Some(id);
|
||||
self.fresh = None;
|
||||
self.caret = caret;
|
||||
@@ -226,6 +313,7 @@ impl TypeTool {
|
||||
self.goal_x = None;
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
self.typing_ended = false;
|
||||
}
|
||||
|
||||
/// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds).
|
||||
@@ -326,6 +414,68 @@ impl TypeTool {
|
||||
out.push(Action::Notify("text.editNew".into()));
|
||||
out
|
||||
}
|
||||
|
||||
/// Delete plain-text byte ranges in one typing session, shifting the caret, anchor and any
|
||||
/// pre-edit range so the selection's direction and the marked text are preserved.
|
||||
fn delete_ranges(&mut self, cx: &ToolContext, ranges: &[(usize, usize)]) -> Vec<Action> {
|
||||
let Some(id) = self.editing else { return vec![] };
|
||||
let Some(t) = Self::text(cx, id) else {
|
||||
self.editing = None;
|
||||
return vec![];
|
||||
};
|
||||
let mut out = vec![];
|
||||
// The document changed under us (undo, a panel edit): start a new session.
|
||||
if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) {
|
||||
out.push(Action::Commit);
|
||||
self.typing = None;
|
||||
// The marked text belonged to the replaced session; it must not be sliced out of the
|
||||
// new document text by a stale range.
|
||||
self.preedit = None;
|
||||
}
|
||||
if self.typing.is_none() {
|
||||
out.push(Action::Begin("Typing".into()));
|
||||
self.typing_ended = false;
|
||||
}
|
||||
let len = match &self.typing {
|
||||
Some(ty) => edit::runs_len(&ty.cur),
|
||||
None => edit::runs_len(&t.runs),
|
||||
};
|
||||
let mut sorted: Vec<(usize, usize)> = ranges.iter().map(|&(a, b)| (a.min(b).min(len), a.max(b).min(len))).filter(|(a, b)| a < b).collect();
|
||||
sorted.sort_unstable();
|
||||
if sorted.is_empty() {
|
||||
return out;
|
||||
}
|
||||
// Shift the selection ends and the pre-edit by the bytes removed before them. The ranges
|
||||
// lie outside the selection/pre-edit, so both ends move by the same preceding amount and
|
||||
// a reversed selection stays reversed.
|
||||
let shift = |at: usize| -> usize {
|
||||
let removed: usize = sorted.iter().map(|&(a, b)| if at >= b { b - a } else { at.saturating_sub(a) }).sum();
|
||||
at.saturating_sub(removed)
|
||||
};
|
||||
let (caret, anchor) = (self.caret.min(len), self.anchor.min(len));
|
||||
self.caret = shift(caret);
|
||||
self.anchor = shift(anchor);
|
||||
if let Some(p) = &mut self.preedit {
|
||||
p.range.start = shift(p.range.start.min(len));
|
||||
p.range.end = shift(p.range.end.min(len));
|
||||
}
|
||||
self.goal_x = None;
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
|
||||
// Delete from the end so earlier offsets stay valid; each call floors to a char boundary
|
||||
// and the one Preview below still makes the whole session one undo step.
|
||||
for &(a, b) in sorted.iter().rev() {
|
||||
ty.lo = ty.lo.min(a);
|
||||
edit::replace_range(&mut ty.cur, a, b, "");
|
||||
}
|
||||
// One preview for the whole session, exactly as `replace` does.
|
||||
let base: String = ty.base.iter().map(|r| r.text.as_str()).collect();
|
||||
let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect();
|
||||
let (p, s) = common_affixes(&base, &cur, ty.lo);
|
||||
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
|
||||
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
|
||||
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the
|
||||
@@ -555,6 +705,115 @@ fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) {
|
||||
(p, s)
|
||||
}
|
||||
|
||||
/// Byte length of the borrowed plain-text segments.
|
||||
fn plain_len(segments: &[&str]) -> usize {
|
||||
segments.iter().map(|s| s.len()).sum()
|
||||
}
|
||||
|
||||
/// Is `byte` a UTF-8 boundary in the concatenation of `segments`? Run edges are boundaries.
|
||||
fn plain_is_boundary(segments: &[&str], byte: usize) -> bool {
|
||||
let mut at = 0usize;
|
||||
for segment in segments {
|
||||
if byte == at {
|
||||
return true;
|
||||
}
|
||||
if byte < at + segment.len() {
|
||||
return segment.is_char_boundary(byte - at);
|
||||
}
|
||||
at += segment.len();
|
||||
}
|
||||
byte == at
|
||||
}
|
||||
|
||||
/// Append the bytes `start..end` of the concatenation of `segments` to `out`.
|
||||
fn copy_plain_range(segments: &[&str], start: usize, end: usize, out: &mut String) {
|
||||
let mut at = 0usize;
|
||||
for segment in segments {
|
||||
let segment_end = at + segment.len();
|
||||
let from = start.max(at);
|
||||
let to = end.min(segment_end);
|
||||
if from < to {
|
||||
let (a, b) = (from - at, to - at);
|
||||
if segment.is_char_boundary(a) && segment.is_char_boundary(b) {
|
||||
out.push_str(&segment[a..b]);
|
||||
}
|
||||
}
|
||||
if segment_end >= end {
|
||||
break;
|
||||
}
|
||||
at = segment_end;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `i` back to a character boundary in the concatenation of `segments` (deleting fewer
|
||||
/// bytes rather than splitting UTF-8).
|
||||
fn plain_floor_boundary(segments: &[&str], mut i: usize) -> usize {
|
||||
i = i.min(plain_len(segments));
|
||||
while i > 0 && !plain_is_boundary(segments, i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Move `i` forward to a character boundary in the concatenation of `segments`.
|
||||
fn plain_ceil_boundary(segments: &[&str], mut i: usize) -> usize {
|
||||
let len = plain_len(segments);
|
||||
i = i.min(len);
|
||||
while i < len && !plain_is_boundary(segments, i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// The byte offset `n` characters before `at` (a boundary) in `segments`, stopping at the start of
|
||||
/// the text. Walks the runs, so a large IME deletion count never copies the whole text.
|
||||
fn plain_chars_before(segments: &[&str], at: usize, n: usize) -> usize {
|
||||
if n == 0 {
|
||||
return at;
|
||||
}
|
||||
let mut remaining = n;
|
||||
let mut end = plain_len(segments);
|
||||
for segment in segments.iter().rev() {
|
||||
let start = end - segment.len();
|
||||
let local_end = at.min(end).saturating_sub(start);
|
||||
if local_end > 0 && segment.is_char_boundary(local_end) {
|
||||
let before = &segment[..local_end];
|
||||
let count = before.chars().count();
|
||||
if count >= remaining {
|
||||
return start + before.char_indices().rev().nth(remaining.saturating_sub(1)).map_or(0, |(i, _)| i);
|
||||
}
|
||||
remaining -= count;
|
||||
}
|
||||
end = start;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// The byte offset `n` characters after `at` (a boundary) in `segments`, stopping at the end of
|
||||
/// the text.
|
||||
fn plain_chars_after(segments: &[&str], at: usize, n: usize) -> usize {
|
||||
if n == 0 {
|
||||
return at;
|
||||
}
|
||||
let len = plain_len(segments);
|
||||
let mut remaining = n;
|
||||
let mut acc = 0usize;
|
||||
for segment in segments {
|
||||
let end = acc + segment.len();
|
||||
let local_start = at.saturating_sub(acc);
|
||||
if end > at && segment.is_char_boundary(local_start) {
|
||||
let after = &segment[local_start..];
|
||||
let count = after.chars().count();
|
||||
if count >= remaining {
|
||||
return acc + local_start + after.char_indices().nth(remaining).map_or(after.len(), |(i, _)| i);
|
||||
}
|
||||
remaining -= count;
|
||||
}
|
||||
acc = end;
|
||||
}
|
||||
len
|
||||
}
|
||||
|
||||
impl Tool for TypeTool {
|
||||
fn id(&self) -> &'static str {
|
||||
match (self.mode, self.vertical) {
|
||||
@@ -643,7 +902,6 @@ impl Tool for TypeTool {
|
||||
if self.editing.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
|
||||
// An IME commit replaces its marked text (an empty commit just removes it).
|
||||
let committing = self.preedit.is_some();
|
||||
if committing && s.is_empty() {
|
||||
@@ -653,19 +911,7 @@ impl Tool for TypeTool {
|
||||
self.anchor = p.range.start;
|
||||
self.caret = p.range.end;
|
||||
}
|
||||
if s.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's
|
||||
// opening or closing quote, by the character before it.
|
||||
let setup = &cx.doc.setup;
|
||||
let s = if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") {
|
||||
let text = self.plain(cx);
|
||||
let mut prev = text.get(..self.caret.min(self.anchor)).and_then(|b| b.chars().next_back());
|
||||
setup.quotes.apply(&s, &mut prev)
|
||||
} else {
|
||||
s
|
||||
};
|
||||
let Some(s) = self.input_text(cx, s) else { return vec![] };
|
||||
self.clicks = (None, 0);
|
||||
// Pasting what we copied keeps its formatting (unless pasted text is kept plain).
|
||||
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
|
||||
@@ -675,6 +921,40 @@ impl Tool for TypeTool {
|
||||
}
|
||||
self.replace(cx, &s, None)
|
||||
}
|
||||
/// Would [`Tool::text_input`] change `s` before inserting it? Control characters are dropped,
|
||||
/// `\r` becomes `\n`, Typographer's Quotes rewrites a straight quote, and copied text keeps
|
||||
/// its run styles. An IME commit the application rewrites is an application edit, not an
|
||||
/// accepted IME insertion (its receipt must stay `Other`).
|
||||
fn text_input_is_verbatim(&self, cx: &ToolContext, s: &str) -> bool {
|
||||
if self.editing.is_none() {
|
||||
return true;
|
||||
}
|
||||
if s.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// A straight quote's direction depends on the character before the insertion. During a
|
||||
// commit this check still sees the pre-edit at the caret, while `text_input` replaces it
|
||||
// from its start; with a custom pair whose opening quote is straight, that can look
|
||||
// verbatim here and then be rewritten. Never claim verbatim for one.
|
||||
if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") {
|
||||
return false;
|
||||
}
|
||||
let Some(normalized) = self.input_text(cx, s) else { return false };
|
||||
if normalized != s {
|
||||
return false;
|
||||
}
|
||||
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
|
||||
!(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text)
|
||||
}
|
||||
fn ime_session_matches_document(&self, cx: &ToolContext) -> bool {
|
||||
match (self.editing, &self.typing) {
|
||||
(Some(id), Some(ty)) => Self::text(cx, id).is_none_or(|t| ty.cur == t.runs),
|
||||
// An outside command settled the session: the document may have changed under it, and
|
||||
// the commit that starts a new session cannot claim the IME made that change.
|
||||
(Some(_), None) => !self.typing_ended,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
// The IME owns the keys while it composes (the UI doesn't send them; this is a guard).
|
||||
if self.preedit.is_some() {
|
||||
@@ -841,6 +1121,102 @@ impl Tool for TypeTool {
|
||||
self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) });
|
||||
out
|
||||
}
|
||||
fn ime_delete_surrounding(&mut self, cx: &ToolContext, before: usize, after: usize, unit: SurroundingUnit) -> Vec<Action> {
|
||||
let Some(id) = self.editing else { return vec![] };
|
||||
// The document changed under the session (undo, a panel edit): the ranges below would index
|
||||
// text that is no longer there. The commit that follows resets the session; do not delete
|
||||
// from the wrong text in the meantime.
|
||||
if let Some(t) = Self::text(cx, id)
|
||||
&& self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs)
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
// Walk the runs in place: joining the whole edited text here would copy a large document
|
||||
// for every IME deletion event, though only the characters next to the selection matter.
|
||||
let Some(segments) = self.plain_segments(cx) else { return vec![] };
|
||||
let len = plain_len(&segments);
|
||||
// The text the IME's following commit replaces (the selection, or the marked text while
|
||||
// composing). The surrounding deletion never removes it: it deletes only the bytes/characters
|
||||
// before and after it, preserving the selection direction and the composition.
|
||||
let (raw_start, raw_end) = match &self.preedit {
|
||||
Some(p) => (p.range.start.min(len), p.range.end.min(len)),
|
||||
None => (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)),
|
||||
};
|
||||
let (before_start, start, end, after_end) = match unit {
|
||||
SurroundingUnit::Bytes => {
|
||||
// Inward rounding: a count ending inside a scalar leaves that scalar whole. The
|
||||
// preceding side moves its start forward (ceil), the following side moves its end
|
||||
// backward (floor). Do not reverse these: that would delete part of a character.
|
||||
let start = plain_floor_boundary(&segments, raw_start);
|
||||
let end = plain_ceil_boundary(&segments, raw_end);
|
||||
let before_start = plain_ceil_boundary(&segments, start.saturating_sub(before));
|
||||
let after_end = plain_floor_boundary(&segments, end.saturating_add(after));
|
||||
(before_start, start, end, after_end)
|
||||
}
|
||||
SurroundingUnit::Chars => {
|
||||
let start = plain_floor_boundary(&segments, raw_start);
|
||||
let end = plain_ceil_boundary(&segments, raw_end);
|
||||
let before_start = plain_chars_before(&segments, start, before);
|
||||
let after_end = plain_chars_after(&segments, end, after);
|
||||
(before_start, start, end, after_end)
|
||||
}
|
||||
};
|
||||
let mut ranges = Vec::with_capacity(2);
|
||||
if before_start < start {
|
||||
ranges.push((before_start, start));
|
||||
}
|
||||
if end < after_end {
|
||||
ranges.push((end, after_end));
|
||||
}
|
||||
if ranges.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
self.delete_ranges(cx, &ranges)
|
||||
}
|
||||
fn ime_target(&self, cx: &ToolContext) -> Option<(u64, u64, u64)> {
|
||||
let id = self.editing?;
|
||||
Self::text(cx, id)?;
|
||||
Some((cx.revision.0, id.0, self.edit_session))
|
||||
}
|
||||
fn ime_surrounding(&self, cx: &ToolContext, max_bytes: usize) -> ImeSurrounding {
|
||||
let Some(id) = self.editing else {
|
||||
return ImeSurrounding::Unavailable("no text object is being edited");
|
||||
};
|
||||
let Some(current) = Self::text(cx, id) else {
|
||||
return ImeSurrounding::Unavailable("the edited text object is gone");
|
||||
};
|
||||
// The document changed under the typing session (undo, a panel or control edit): the
|
||||
// session's runs, marked-text range and caret offsets no longer describe the document.
|
||||
// Publish the document's own text (never the stale session's, which the IME cannot see);
|
||||
// the marked text has no range in it, so it is excluded from the logical view. The
|
||||
// commit-time check (`ime_session_matches_document`) reports the reset as application
|
||||
// origin instead of letting a receipt describe the wrong text.
|
||||
let stale = self.typing.as_ref().is_some_and(|ty| ty.cur != current.runs);
|
||||
if self.preedit.is_some() && self.typing.is_none() {
|
||||
return ImeSurrounding::Unavailable("the IME pre-edit has no typing session");
|
||||
}
|
||||
let Some(segments) = self.plain_segments(cx) else {
|
||||
return ImeSurrounding::Unavailable("the edited text object is gone");
|
||||
};
|
||||
let len = plain_len(&segments);
|
||||
let caret = self.caret.min(len);
|
||||
let anchor = self.anchor.min(len);
|
||||
if !plain_is_boundary(&segments, caret) || !plain_is_boundary(&segments, anchor) {
|
||||
return ImeSurrounding::Unavailable("the caret or selection anchor is not a UTF-8 boundary");
|
||||
}
|
||||
let preedit = match self.preedit.as_ref().filter(|_| !stale) {
|
||||
Some(p) => {
|
||||
let start = p.range.start.min(len);
|
||||
let end = p.range.end.min(len);
|
||||
if start > end || !plain_is_boundary(&segments, start) || !plain_is_boundary(&segments, end) {
|
||||
return ImeSurrounding::Unavailable("the IME pre-edit is not on UTF-8 boundaries");
|
||||
}
|
||||
Some(start..end)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes)
|
||||
}
|
||||
fn composing(&self) -> bool {
|
||||
self.preedit.is_some()
|
||||
}
|
||||
@@ -928,6 +1304,7 @@ impl Tool for TypeTool {
|
||||
"commitTyping" | "endTyping" => {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
self.typing_ended = true;
|
||||
}
|
||||
"selectAll" if self.editing.is_some() => {
|
||||
self.anchor = 0;
|
||||
@@ -1041,6 +1418,90 @@ impl Tool for TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical IME surrounding window: the pre-edit is removed and the caret/anchor offsets
|
||||
/// are remapped around it. `preedit` has already been validated against `segments`.
|
||||
fn ime_surrounding_window(
|
||||
segments: &[&str],
|
||||
preedit: Option<Range<usize>>,
|
||||
len: usize,
|
||||
caret: usize,
|
||||
anchor: usize,
|
||||
max_bytes: usize,
|
||||
) -> ImeSurrounding {
|
||||
let removed = preedit.as_ref().map_or(0, |r| r.end - r.start);
|
||||
let logical_len = len - removed;
|
||||
let to_logical = |byte: usize| -> usize {
|
||||
match &preedit {
|
||||
Some(r) if byte > r.start && byte < r.end => r.start,
|
||||
Some(r) if byte >= r.end => byte - removed,
|
||||
_ => byte,
|
||||
}
|
||||
};
|
||||
let cursor = to_logical(caret);
|
||||
let anchor = to_logical(anchor);
|
||||
let selection_start = cursor.min(anchor);
|
||||
let selection_end = cursor.max(anchor);
|
||||
let selection_len = selection_end - selection_start;
|
||||
if selection_len > max_bytes {
|
||||
return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit");
|
||||
}
|
||||
let budget = max_bytes - selection_len;
|
||||
let before_available = selection_start;
|
||||
let after_available = logical_len - selection_end;
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let mut window_start = selection_start - before;
|
||||
let mut window_end = selection_end + after;
|
||||
let logical_boundary = |byte: usize| -> bool {
|
||||
match &preedit {
|
||||
Some(r) if byte == r.start => true,
|
||||
Some(r) if byte < r.start => plain_is_boundary(segments, byte),
|
||||
Some(_) => plain_is_boundary(segments, byte + removed),
|
||||
None => plain_is_boundary(segments, byte),
|
||||
}
|
||||
};
|
||||
while window_start < selection_start && !logical_boundary(window_start) {
|
||||
window_start += 1;
|
||||
}
|
||||
while selection_end < window_end && !logical_boundary(window_end) {
|
||||
window_end -= 1;
|
||||
}
|
||||
let to_original = |byte: usize| -> usize {
|
||||
match &preedit {
|
||||
Some(r) if byte > r.start => byte + removed,
|
||||
_ => byte,
|
||||
}
|
||||
};
|
||||
let original_start = to_original(window_start);
|
||||
let original_end = to_original(window_end);
|
||||
let mut text = String::with_capacity(window_end - window_start);
|
||||
match &preedit {
|
||||
Some(r) => {
|
||||
if window_start < r.start {
|
||||
let first_end = original_end.min(r.start);
|
||||
if original_start < first_end {
|
||||
copy_plain_range(segments, original_start, first_end, &mut text);
|
||||
}
|
||||
}
|
||||
if r.start < window_end {
|
||||
let second_start = r.end.max(original_start);
|
||||
if second_start < original_end {
|
||||
copy_plain_range(segments, second_start, original_end, &mut text);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if original_start < original_end {
|
||||
copy_plain_range(segments, original_start, original_end, &mut text);
|
||||
}
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length");
|
||||
ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! `tests_textedit`).
|
||||
|
||||
use super::*;
|
||||
use crate::ImeSurrounding;
|
||||
use crate::testutil::{cx, paint};
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, Selection, TextObject};
|
||||
use vectorcraft_text::FontDb;
|
||||
@@ -469,6 +470,29 @@ fn ime_ranges_convert_characters_to_bytes() {
|
||||
assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards");
|
||||
}
|
||||
|
||||
/// Outgoing surrounding text excludes the pre-edit and remaps a selection that crosses it, not
|
||||
/// only a caret at its end.
|
||||
#[test]
|
||||
fn ime_surrounding_remaps_a_selection_across_the_preedit() {
|
||||
let (mut d, _id, mut tool) = editing("abcd");
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.caret = 2;
|
||||
tool.anchor = 2;
|
||||
let acts = tool.ime_preedit(&cx(&d, &sel, &p), "xy", None);
|
||||
apply(&mut d, &snap, &acts);
|
||||
assert_eq!(tool.preedit.as_ref().map(|p| p.range.clone()), Some(2..4));
|
||||
// A selection from before the marked text to after it: the IME receives the committed text
|
||||
// with both offsets folded across the removed pre-edit.
|
||||
tool.anchor = 1;
|
||||
tool.caret = 6;
|
||||
let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else {
|
||||
panic!("the pre-edit has committed text around it");
|
||||
};
|
||||
assert_eq!(text, "abcd");
|
||||
assert_eq!((cursor, anchor), (4, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_text_is_underlined_with_the_converting_clause_thick() {
|
||||
let (mut d, id, mut tool) = editing("曲");
|
||||
@@ -878,3 +902,200 @@ fn type_on_a_path_has_its_ports_beyond_its_brackets() {
|
||||
assert!(near(port_centre(t, Port::Out, 1.0), Point::new(400.0 + PORT_RISE_PX, 300.0)), "{:?}", port_centre(t, Port::Out, 1.0));
|
||||
assert!(near(port_centre(t, Port::In, 0.5), Point::new(160.0 - PORT_RISE_PX / 2.0, 300.0)), "screen pixels at 200%");
|
||||
}
|
||||
|
||||
/// The outgoing query copies only the 4 KiB window around the selection, not the whole document,
|
||||
/// and reports offsets into that window.
|
||||
#[test]
|
||||
fn ime_surrounding_bounds_a_long_document() {
|
||||
let source: String = (0..10_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect();
|
||||
let (d, _id, mut tool) = editing(&source);
|
||||
tool.caret = 5000;
|
||||
tool.anchor = 5000;
|
||||
let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else {
|
||||
panic!("a caret in a long document is available");
|
||||
};
|
||||
assert_eq!(text.len(), 4000, "only the bounded window is copied");
|
||||
assert_eq!(text.as_str(), &source[3000..7000]);
|
||||
assert_eq!((cursor, anchor), (2000, 2000), "offsets are relative to the returned window");
|
||||
|
||||
tool.anchor = 0;
|
||||
let ImeSurrounding::Unavailable(reason) = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else {
|
||||
panic!("a selection wider than the limit must be explicit");
|
||||
};
|
||||
assert!(reason.contains("selection"), "{reason}");
|
||||
}
|
||||
|
||||
/// Unicode offsets are UTF-8 bytes and a reversed selection stays reversed through the query.
|
||||
#[test]
|
||||
fn ime_surrounding_preserves_reversed_unicode_selection_and_empty_text() {
|
||||
let (d, _id, mut tool) = editing("aé😀z");
|
||||
tool.caret = 1;
|
||||
tool.anchor = 7;
|
||||
let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else {
|
||||
panic!("the short text is available");
|
||||
};
|
||||
assert_eq!(text, "aé😀z");
|
||||
assert_eq!((cursor, anchor), (1, 7), "cursor is the primary end, anchor the other");
|
||||
|
||||
let (d, _id, tool) = editing("");
|
||||
let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else {
|
||||
panic!("an empty document is available, not unavailable");
|
||||
};
|
||||
assert_eq!((text.as_str(), cursor, anchor), ("", 0, 0));
|
||||
}
|
||||
|
||||
/// Deleting around a selection removes only the surrounding text, keeps the reversed selection
|
||||
/// direction, and leaves it for the following commit to replace.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_preserves_a_reversed_selection() {
|
||||
let (mut d, _id, mut tool) = editing("abcd");
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.anchor = 3; // "bc", selected backwards
|
||||
tool.caret = 1;
|
||||
let acts = tool.ime_delete_surrounding(&cx(&d, &sel, &p), 1, 1, SurroundingUnit::Bytes);
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(_id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "bc", "the selection itself is preserved");
|
||||
assert_eq!((tool.caret, tool.anchor), (0, 2), "direction is kept, shifted left by the preceding deletion");
|
||||
let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else {
|
||||
panic!("the selection is still an available context");
|
||||
};
|
||||
assert_eq!((text.as_str(), cursor, anchor), ("bc", 0, 2));
|
||||
}
|
||||
|
||||
/// Deleting around a composition removes only the surrounding committed text, keeps the pre-edit
|
||||
/// in place for the commit to replace, and remaps the composition range.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_preserves_the_preedit() {
|
||||
let (mut d, id, mut tool) = editing("abcd");
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.caret = 2;
|
||||
tool.anchor = 2;
|
||||
let acts = tool.ime_preedit(&cx(&d, &sel, &p), "xy", None);
|
||||
apply(&mut d, &snap, &acts);
|
||||
assert_eq!(tool.preedit.as_ref().map(|p| p.range.clone()), Some(2..4));
|
||||
let acts = tool.ime_delete_surrounding(&cx(&d, &sel, &p), 1, 0, SurroundingUnit::Bytes);
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "axycd", "only the byte before the composition is deleted");
|
||||
assert_eq!(tool.preedit.as_ref().map(|p| p.range.clone()), Some(1..3), "the composition stayed and moved left");
|
||||
let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else {
|
||||
panic!("the composition has committed text around it");
|
||||
};
|
||||
assert_eq!((text.as_str(), cursor, anchor), ("acd", 1, 1), "the pre-edit is excluded and remapped");
|
||||
}
|
||||
|
||||
/// Edit-session identity changes only when editing starts afresh, not on ordinary edits or caret
|
||||
/// moves; the owner keeps the document uid and target id.
|
||||
#[test]
|
||||
fn ime_target_is_stable_within_an_edit_session() {
|
||||
let (d, id, mut tool) = editing("abc");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let first = tool.ime_target(&cx(&d, &sel, &p)).expect("editing has an owner");
|
||||
assert_eq!((first.0, first.1), (0, id.0));
|
||||
tool.caret = 1;
|
||||
tool.anchor = 3;
|
||||
assert_eq!(tool.ime_target(&cx(&d, &sel, &p)), Some(first), "caret and selection moves keep the context");
|
||||
tool.start_editing(id, 0);
|
||||
let second = tool.ime_target(&cx(&d, &sel, &p)).expect("re-entering has an owner");
|
||||
assert_eq!((second.0, second.1), (first.0, first.1));
|
||||
assert_ne!(second.2, first.2, "a new edit session is a replacement epoch");
|
||||
}
|
||||
|
||||
/// The bounded query walks the styled runs without joining them, and its byte offsets still refer
|
||||
/// to the concatenated logical text.
|
||||
#[test]
|
||||
fn ime_surrounding_maps_across_styled_runs() {
|
||||
use vectorcraft_doc::TextRun;
|
||||
let (mut d, id, mut tool) = editing("aé😀z");
|
||||
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!() };
|
||||
t.runs =
|
||||
vec![TextRun::new("aé", CharStyle { size: 20.0, ..Default::default() }), TextRun::new("😀z", CharStyle { size: 24.0, ..Default::default() })];
|
||||
tool.caret = 1;
|
||||
tool.anchor = 7;
|
||||
let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else {
|
||||
panic!("the short text is available");
|
||||
};
|
||||
assert_eq!((text.as_str(), cursor, anchor), ("aé😀z", 1, 7));
|
||||
}
|
||||
|
||||
/// Deletion counts start at the caret, including when it is in a later styled run. Character
|
||||
/// units count Unicode scalars; byte units round inward instead of cutting a multibyte scalar.
|
||||
#[test]
|
||||
fn ime_character_deletion_walks_styled_runs_backwards_from_the_caret() {
|
||||
use vectorcraft_doc::TextRun;
|
||||
for (caret, before, after, unit, expected, shifted, committed) in [
|
||||
(7, 1, 0, SurroundingUnit::Chars, "aéz!x", 3, "aéQz!x"),
|
||||
(8, 3, 0, SurroundingUnit::Chars, "a!x", 1, "aQ!x"),
|
||||
(7, 3, 0, SurroundingUnit::Bytes, "aé😀z!x", 7, "aé😀Qz!x"),
|
||||
(7, 1, 1, SurroundingUnit::Chars, "aé!x", 3, "aéQ!x"),
|
||||
(7, 4, 1, SurroundingUnit::Bytes, "aé!x", 3, "aéQ!x"),
|
||||
(9, 1, 1, SurroundingUnit::Chars, "aé😀z", 8, "aé😀zQ"),
|
||||
] {
|
||||
let (mut d, id, mut tool) = editing("aé😀z!x");
|
||||
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!() };
|
||||
t.runs = vec![
|
||||
TextRun::new("aé", CharStyle { size: 20.0, ..Default::default() }),
|
||||
TextRun::new("😀z", CharStyle { size: 24.0, ..Default::default() }),
|
||||
TextRun::new("!x", CharStyle { size: 28.0, ..Default::default() }),
|
||||
];
|
||||
tool.caret = caret;
|
||||
tool.anchor = caret;
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let owner = tool.ime_target(&cx(&d, &sel, &p));
|
||||
let acts = tool.ime_delete_surrounding(&cx(&d, &sel, &p), before, after, unit);
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), expected, "caret={caret} before={before} after={after} unit={unit:?}");
|
||||
assert_eq!((tool.caret, tool.anchor), (shifted, shifted));
|
||||
assert_eq!(tool.ime_target(&cx(&d, &sel, &p)), owner, "deletion preserves the immutable edit target");
|
||||
let acts = tool.text_input(&cx(&d, &sel, &p), "Q");
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), committed, "the following commit uses the shifted caret");
|
||||
}
|
||||
}
|
||||
|
||||
/// A recreated Type tool (a document switch, File › Revert) must not reuse the replacement epoch
|
||||
/// of the same document and text node: a still-cached native IME context would look current.
|
||||
#[test]
|
||||
fn a_recreated_type_tool_gets_a_new_replacement_epoch() {
|
||||
let (d, id) = doc_with_text("Same");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let mut first = TypeTool::new("type");
|
||||
first.start_editing(id, 0);
|
||||
let a = first.ime_target(&cx(&d, &sel, &p)).expect("the first tool edits");
|
||||
let mut second = TypeTool::new("type");
|
||||
second.start_editing(id, 0);
|
||||
let b = second.ime_target(&cx(&d, &sel, &p)).expect("the recreated tool edits");
|
||||
assert_eq!((a.0, a.1), (b.0, b.1), "the owner key is the document and text node");
|
||||
assert_ne!(a.2, b.2, "edit sessions are numbered process-wide, not per tool");
|
||||
}
|
||||
|
||||
/// The surrounding query never publishes a typing session's text after the document changed
|
||||
/// outside it: it reports the document's own text (and drops the stale marked-text range), and the
|
||||
/// commit-time session check keeps the reset from earning an IME receipt.
|
||||
#[test]
|
||||
fn ime_surrounding_reports_the_document_when_it_changed_under_the_session() {
|
||||
let (mut d, id, mut tool) = editing("abc");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.caret = 3;
|
||||
tool.anchor = 3;
|
||||
let snap = d.clone();
|
||||
let acts = tool.text_input(&cx(&d, &sel, &p), "x");
|
||||
apply(&mut d, &snap, &acts);
|
||||
let ImeSurrounding::Available { text, .. } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else {
|
||||
panic!("the session matches the document");
|
||||
};
|
||||
assert_eq!(text, "abcx");
|
||||
// A panel, menu or control edit replaces the text while the typing session is open.
|
||||
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!() };
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let _ = edit::replace_range(&mut t.runs, 0, len, "XYZ");
|
||||
let stale = tool.ime_surrounding(&cx(&d, &sel, &p), 4000);
|
||||
assert_eq!(stale, ImeSurrounding::Available { text: "XYZ".into(), cursor: 3, anchor: 3 });
|
||||
assert!(!tool.ime_session_matches_document(&cx(&d, &sel, &p)), "the commit starts a fresh session");
|
||||
}
|
||||
|
||||
@@ -38,8 +38,11 @@ vectorcraft-pdf = { workspace = true }
|
||||
vectorcraft-cad = { workspace = true }
|
||||
vectorcraft-eps = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
egui = { workspace = true, features = ["bytemuck"] }
|
||||
egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] }
|
||||
# With egui's `bytemuck`: copies rendered rasters into egui images whole (`canvas::color_image`).
|
||||
# `default_fonts`: egui 0.37 makes its bundled faces opt-in, and a dependency's dev-features do not
|
||||
# reach our tests; `theme::install_fonts` starts from `FontDefinitions::default()`, which is empty
|
||||
# without them (production would lay out tofu, and the no-interface-fonts tests would bind nothing).
|
||||
bytemuck = "1"
|
||||
egui_extras = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
@@ -20,7 +20,7 @@ fn decode() -> egui::ColorImage {
|
||||
|
||||
/// The icon texture, uploaded on first use and kept in the context.
|
||||
fn texture(ctx: &Context) -> TextureHandle {
|
||||
let id = Id::new("vectorcraft-brand-mark");
|
||||
let id = Id::unique("vectorcraft-brand-mark");
|
||||
if let Some(tex) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
|
||||
return tex;
|
||||
}
|
||||
|
||||
+370
-53
@@ -132,15 +132,15 @@ pub(crate) fn end_document_drag(ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("canvas-drag")
|
||||
egui::Id::unique("canvas-drag")
|
||||
}
|
||||
/// Where the pointer was last seen during a press on the canvas.
|
||||
fn drag_pos_id() -> egui::Id {
|
||||
egui::Id::new("canvas-drag-pos")
|
||||
egui::Id::unique("canvas-drag-pos")
|
||||
}
|
||||
/// The layer the canvas was last drawn on: a release over another layer is over a panel (#2826).
|
||||
fn drag_layer_id() -> egui::Id {
|
||||
egui::Id::new("canvas-drag-layer")
|
||||
egui::Id::unique("canvas-drag-layer")
|
||||
}
|
||||
/// Whether `p` is over the canvas itself, not a panel or a floating window over it.
|
||||
fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||||
@@ -149,11 +149,11 @@ fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||||
/// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press
|
||||
/// has dragged.
|
||||
fn tool_drag_doc_id() -> egui::Id {
|
||||
egui::Id::new("canvas-tool-drag-doc")
|
||||
egui::Id::unique("canvas-tool-drag-doc")
|
||||
}
|
||||
/// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down.
|
||||
fn tool_press_doc_id() -> egui::Id {
|
||||
egui::Id::new("canvas-tool-press-doc")
|
||||
egui::Id::unique("canvas-tool-press-doc")
|
||||
}
|
||||
/// Whether the tool should get another drag event, and if so records `p` as the last position
|
||||
/// sent: the pointer moved this frame, or its document position changed without a delta (#834).
|
||||
@@ -180,7 +180,7 @@ fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> b
|
||||
/// until a pen reports it). A mouse presses fully (1).
|
||||
fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 {
|
||||
let stylus = app.services.stylus.as_ref();
|
||||
let id = egui::Id::new("canvas-pressure");
|
||||
let id = egui::Id::unique("canvas-pressure");
|
||||
let force = ui.input(|i| {
|
||||
i.events.iter().rev().find_map(|e| match e {
|
||||
egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)),
|
||||
@@ -277,11 +277,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// that does not draw the overlay drops them, for example with the option off, with the edges
|
||||
// hidden, on the Home screen or with no document open.
|
||||
let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars);
|
||||
// The IME geometry this pass, if the Type tool gets one; `ime::end_of_pass` consumes it after
|
||||
// the dialogs and panels draw.
|
||||
app.ime_caret_rect = None;
|
||||
// A document that opened, closed or became active since Home was chosen replaces it.
|
||||
if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) {
|
||||
app.ui.home = None;
|
||||
}
|
||||
if crate::menus::home_showing(app) {
|
||||
// No IME owner this pass (`ime_caret_rect` stays None): the end-of-pass observation marks
|
||||
// the context absent and keeps any marked text as typed.
|
||||
home(app, ui, full);
|
||||
return;
|
||||
}
|
||||
@@ -305,7 +310,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if bars {
|
||||
scroll_bars(app, ui, rect, outer, &t);
|
||||
}
|
||||
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
|
||||
let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag());
|
||||
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
|
||||
// alone would leave open); the tool doesn't get it.
|
||||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) {
|
||||
@@ -682,7 +687,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if !revolve_edit {
|
||||
shaper_widget_overlay(app, &painter, &xf);
|
||||
}
|
||||
ime_output(app, ui.ctx(), &xf);
|
||||
ime_geometry(app, ui.ctx(), &xf);
|
||||
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
|
||||
// A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial
|
||||
// at the canvas center, the point the view turns about.
|
||||
@@ -742,7 +747,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// In Presentation Mode the pointer is the default one, with no tool cursor.
|
||||
let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still)));
|
||||
let cur = if let Some(c) = tool_cursor.flatten() {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")));
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tool-cursor")));
|
||||
// The loaded place cursor carries the file's thumbnail.
|
||||
if app.session.tool_id() == "place"
|
||||
&& let Some(hp) = ui.input(|i| i.pointer.hover_pos())
|
||||
@@ -936,7 +941,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
|
||||
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
|
||||
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
|
||||
let turn_id = egui::Id::new("canvas-wheel-turn");
|
||||
let turn_id = egui::Id::unique("canvas-wheel-turn");
|
||||
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
|
||||
let mut turn = turn_before;
|
||||
let (pointer, m, space, (factor, scroll)) =
|
||||
@@ -987,7 +992,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
|
||||
// without moving the mouse (Alt switches the Shape Builder to erase mode).
|
||||
let mods_changed = ui.data_mut(|d| {
|
||||
let id = egui::Id::new("canvas-mods");
|
||||
let id = egui::Id::unique("canvas-mods");
|
||||
let prev = d.get_temp::<egui::Modifiers>(id);
|
||||
d.insert_temp(id, m);
|
||||
prev.is_some_and(|prev| prev != m)
|
||||
@@ -998,7 +1003,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
// Whether the tool hovered last frame: coming onto the canvas re-hovers it, even after a single
|
||||
// jump egui doesn't count as moving, and leaving it ends the tool's hover (below).
|
||||
let was_over = ui.data_mut(|d| {
|
||||
let id = egui::Id::new("canvas-hovered");
|
||||
let id = egui::Id::unique("canvas-hovered");
|
||||
let prev = d.get_temp::<bool>(id).unwrap_or(false);
|
||||
d.insert_temp(id, hover_tool.is_some());
|
||||
prev
|
||||
@@ -1017,7 +1022,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
doc: app.session.active().map(|st| (st.uid, st.revision)),
|
||||
};
|
||||
let (tool_switched, context_changed) = ui.data_mut(|d| {
|
||||
let id = egui::Id::new("canvas-hover-context");
|
||||
let id = egui::Id::unique("canvas-hover-context");
|
||||
let prev = d.get_temp::<HoverContext>(id);
|
||||
d.insert_temp(id, context);
|
||||
prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context))
|
||||
@@ -1399,7 +1404,7 @@ fn trackpad_turn(ui: &Ui, on: bool) -> Option<f64> {
|
||||
if deg == 0.0 || !deg.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let id = egui::Id::new("canvas-trackpad-turn");
|
||||
let id = egui::Id::unique("canvas-trackpad-turn");
|
||||
let kept: Option<TrackpadTurn> = ui.data(|d| d.get_temp(id));
|
||||
let mut t = kept.filter(|t| now - t.last <= ROTATE_GESTURE_END).unwrap_or(TrackpadTurn { total: 0.0, last: now, turning: false });
|
||||
t.total += deg;
|
||||
@@ -1485,7 +1490,7 @@ fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui:
|
||||
let len = if horizontal { track.width() } else { track.height() };
|
||||
let (at, size) = span.thumb(len);
|
||||
let on_thumb = |p: Pos2| (at..=at + size).contains(&along(p));
|
||||
let id = egui::Id::new(("canvas-scroll-bar", k));
|
||||
let id = egui::Id::unique(("canvas-scroll-bar", k));
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = ui.input(|i| i.pointer.press_origin())
|
||||
@@ -1641,7 +1646,7 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec
|
||||
if touches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::new("canvas-touch");
|
||||
let key = egui::Id::unique("canvas-touch");
|
||||
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
|
||||
let mut tap = None;
|
||||
for (id, phase, pos) in touches {
|
||||
@@ -1775,7 +1780,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Ve
|
||||
/// look, and its cell size in screen points.
|
||||
pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) {
|
||||
let colors = setup.grid_colors.map(|c| crate::panels::c32(&c));
|
||||
let id = egui::Id::new("transparency-grid-tile");
|
||||
let id = egui::Id::unique("transparency-grid-tile");
|
||||
let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors);
|
||||
let tex = cached.map(|(_, t)| t).unwrap_or_else(|| {
|
||||
let [a, b] = colors;
|
||||
@@ -1952,7 +1957,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui
|
||||
let mut unit_clicked = None;
|
||||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||||
// Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right).
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click_and_drag());
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click_and_drag());
|
||||
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
let kind = if resp.drag_stopped() {
|
||||
PointerKind::Up
|
||||
@@ -1972,7 +1977,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
let corner = ui.interact(corner, egui::Id::new("ruler-corner"), Sense::click_and_drag());
|
||||
let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::click_and_drag());
|
||||
corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||||
if let Some((id, p)) = unit_clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
@@ -2016,7 +2021,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui
|
||||
|
||||
/// The key of the ruler origin drag's state in egui's temporary data.
|
||||
fn ruler_origin_drag_id() -> egui::Id {
|
||||
egui::Id::new("ruler-origin-drag")
|
||||
egui::Id::unique("ruler-origin-drag")
|
||||
}
|
||||
|
||||
/// Is a drag from the rulers' corner in progress? It stays true after Escape cancels the move, until
|
||||
@@ -3017,26 +3022,21 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve
|
||||
}
|
||||
}
|
||||
|
||||
/// While the Type tool edits, let the system IME compose (egui-winit allows it only in frames that
|
||||
/// set `ime`) and keep its candidate window under the caret.
|
||||
fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
let ours = app.session.tool_wants_text() && !ctx.egui_wants_keyboard_input() && app.ui.dialog.is_none() && !app.ui.palette_open;
|
||||
let caret = if ours { app.session.tool_ime_caret(app.view_info()) } else { None };
|
||||
let Some((a, b)) = caret else {
|
||||
// The IME goes away with its marked text: keep that text as typed, like a click away.
|
||||
crate::shortcuts::keep_marked_text(app);
|
||||
/// Record where the Type tool's IME candidate window goes this pass, if it has an editor.
|
||||
///
|
||||
/// The descriptor itself is observed once at the end of the pass (`crate::ime::end_of_pass`),
|
||||
/// after dialogs, floating panels, progress windows and the palette draw: any of them can open
|
||||
/// over the canvas, take the keyboard or run a command that changes the edited text, so a
|
||||
/// descriptor written here would carry a stale owner, surrounding text or cause.
|
||||
fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
app.ime_caret_rect = None;
|
||||
// A hidden viewport cannot compose; the end-of-pass observation clears the owner.
|
||||
if !app.session.tool_wants_text() || app.viewport_hidden(ctx) {
|
||||
return;
|
||||
};
|
||||
let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0));
|
||||
// The tool ended the composition itself: the IME must drop what it still has marked.
|
||||
let interrupt = app.ime_marked.is_some() && !app.session.tool_composing();
|
||||
if interrupt {
|
||||
app.ime_marked = None;
|
||||
app.ime_discard = true;
|
||||
}
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: interrupt });
|
||||
});
|
||||
let Some((a, b)) = app.session.tool_ime_caret(app.view_info()) else { return };
|
||||
let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0));
|
||||
app.ime_caret_rect = Some((r, r));
|
||||
}
|
||||
|
||||
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
|
||||
@@ -3044,11 +3044,11 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
|
||||
|
||||
fn name_press_id() -> egui::Id {
|
||||
egui::Id::new("canvas-artboard-name-press")
|
||||
egui::Id::unique("canvas-artboard-name-press")
|
||||
}
|
||||
|
||||
fn name_edit_id() -> egui::Id {
|
||||
egui::Id::new("canvas-artboard-name-edit")
|
||||
egui::Id::unique("canvas-artboard-name-edit")
|
||||
}
|
||||
|
||||
/// A name being typed above an artboard. `fresh`: this frame opened it, so a focus loss from the
|
||||
@@ -3203,7 +3203,14 @@ fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
};
|
||||
let fresh = edit.fresh;
|
||||
edit.fresh = false;
|
||||
let te = ui.put(rect, egui::TextEdit::singleline(&mut edit.text).id(name_edit_id().with("field")).font(egui::FontId::proportional(11.0)));
|
||||
let te = ui.put(
|
||||
rect,
|
||||
egui::TextEdit::singleline(&mut edit.text)
|
||||
.id(name_edit_id().with("field"))
|
||||
.font(egui::FontId::proportional(11.0))
|
||||
// Keep the original inset so theme button padding cannot move the canvas label.
|
||||
.margin(egui::Margin::symmetric(4, 2)),
|
||||
);
|
||||
if fresh || (!te.has_focus() && !te.lost_focus()) {
|
||||
te.request_focus();
|
||||
}
|
||||
@@ -3449,7 +3456,7 @@ fn recent_row(ui: &mut Ui, path: &str) -> egui::Response {
|
||||
ui.painter().galley(egui::pos2(folder_x, rect.center().y - folder_galley.size().y / 2.0), folder_galley, t.text_dim);
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||||
resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
@@ -3545,7 +3552,7 @@ fn pixel_region(xf: &Xf) -> Option<[i64; 4]> {
|
||||
|
||||
/// The Contextual Task Bar's area.
|
||||
fn task_bar_id() -> egui::Id {
|
||||
egui::Id::new("task-bar")
|
||||
egui::Id::unique("task-bar")
|
||||
}
|
||||
|
||||
/// Where the Contextual Task Bar was last drawn (none while it isn't shown).
|
||||
@@ -4602,6 +4609,70 @@ mod tests {
|
||||
assert!(app.ui.dialog.is_none(), "Artboard Options stays closed");
|
||||
}
|
||||
|
||||
/// The canvas artboard name field keeps its own inset: egui 0.37 insets a text field by the theme's button
|
||||
/// padding unless told otherwise, but this field sits over the canvas label, so its glyphs must not move when
|
||||
/// that padding changes (an ordinary themed field's do, as the probe shows).
|
||||
#[test]
|
||||
fn artboard_name_field_glyphs_stay_put_when_button_padding_changes() {
|
||||
/// Where the frame paints `text` (a field's text starts at its galley's top left).
|
||||
fn text_at(shapes: &[Shape], text: &str) -> Pos2 {
|
||||
shapes
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Shape::Text(t) if t.galley.text() == text => Some(t.pos),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no {text:?} text"))
|
||||
}
|
||||
fn raw() -> egui::RawInput {
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }
|
||||
}
|
||||
/// The name field's text origin relative to its rectangle with the theme's button padding `padding`: the
|
||||
/// field opened as a double click opens it, measured in the steady frame after it takes focus.
|
||||
fn editor_inset(padding: Vec2) -> Vec2 {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("artboard.setProps", &json!({"index": 0, "name": "Inset"})).unwrap();
|
||||
app.select_tool("artboard");
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, crate::theme::Brightness::default());
|
||||
ctx.global_style_mut(|s| s.spacing.button_padding = padding);
|
||||
ctx.run_ui(raw(), |ui| begin_artboard_name_edit(ui, &app, 0)).textures_delta.clear();
|
||||
// The first frame opens and focuses the field; the next one is its steady state.
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let (shapes, mut delta) = frame_output(&mut app, &ctx);
|
||||
delta.clear();
|
||||
let origin = text_at(&shapes, "Inset");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let mut field = None;
|
||||
ctx.run_ui(raw(), |ui| field = artboard_name_field_rect(&app, ui, &xf, 0).map(|r| r.min)).textures_delta.clear();
|
||||
origin - field.unwrap()
|
||||
}
|
||||
/// An ordinary themed field's text origin relative to its rectangle: the padding moves it.
|
||||
fn plain_inset(padding: Vec2) -> Vec2 {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, crate::theme::Brightness::default());
|
||||
ctx.global_style_mut(|s| s.spacing.button_padding = padding);
|
||||
let mut text = String::from("Probe");
|
||||
let rect = egui::Rect::from_min_size(pos2(40.0, 40.0), vec2(140.0, 18.0));
|
||||
let mut out = ctx.run_ui(raw(), |ui| {
|
||||
ui.put(rect, egui::TextEdit::singleline(&mut text).id(egui::Id::unique("inset-probe")).font(egui::FontId::proportional(11.0)));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let shapes: Vec<Shape> = out.shapes.into_iter().map(|c| c.shape).collect();
|
||||
text_at(&shapes, "Probe") - rect.min
|
||||
}
|
||||
let (old, themed) = (vec2(4.0, 2.0), vec2(6.0, 2.0));
|
||||
let (editor_old, editor_themed) = (editor_inset(old), editor_inset(themed));
|
||||
let (plain_old, plain_themed) = (plain_inset(old), plain_inset(themed));
|
||||
assert!((plain_themed.x - plain_old.x).abs() > 1.0, "the probe field follows the padding: {plain_old:?} → {plain_themed:?}");
|
||||
assert!((editor_themed - editor_old).length() < 0.01, "the artboard name moves with the button padding: {editor_old:?} → {editor_themed:?}");
|
||||
}
|
||||
|
||||
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
|
||||
/// longer, and an artboard too narrow shows none.
|
||||
#[test]
|
||||
@@ -6586,6 +6657,7 @@ mod tests {
|
||||
delta: vec2(0.0, dy),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
for k in 0..12 {
|
||||
let before = app.view().unwrap().center;
|
||||
@@ -7092,7 +7164,7 @@ mod tests {
|
||||
// A canvas frame through the host's input hook (it feeds the control channel's input).
|
||||
let run = |app: &mut VectorcraftApp| {
|
||||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
app.raw_input_hook(&mut raw);
|
||||
app.raw_input_hook(&ctx, &mut raw);
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
@@ -7146,7 +7218,7 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
let run = |app: &mut VectorcraftApp| {
|
||||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
app.raw_input_hook(&mut raw);
|
||||
app.raw_input_hook(&ctx, &mut raw);
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
@@ -7191,6 +7263,7 @@ mod tests {
|
||||
delta: vec2(0.0, -60.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
let mut events = vec![wheel];
|
||||
let mut moved = 0;
|
||||
@@ -7257,6 +7330,7 @@ mod tests {
|
||||
delta: vec2(0.0, -40.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::SHIFT,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
frame(&mut app, &ctx, vec![wheel]);
|
||||
let after = *app.view().unwrap();
|
||||
@@ -8297,12 +8371,17 @@ mod tests {
|
||||
assert_eq!(st.history.undo.len(), undo + 1, "one move, one undo step");
|
||||
}
|
||||
|
||||
/// One frame of keyboard handling and canvas, as the app runs them; returns the IME output.
|
||||
/// One frame of keyboard handling, canvas and the end-of-pass IME observation, as the app runs
|
||||
/// them; returns the IME output.
|
||||
fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<egui::output::IMEOutput> {
|
||||
VectorcraftApp::install_ime_reset_hooks(ctx);
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
crate::ime::begin_ui(app, ui.ctx());
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
show(app, ui);
|
||||
crate::ime::end_of_pass(app, ui.ctx());
|
||||
crate::ime::finish_ui(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.ime
|
||||
@@ -8522,7 +8601,9 @@ mod tests {
|
||||
assert_eq!(plain(&app, id), "しょうこ");
|
||||
assert!(!app.session.tool_composing());
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]);
|
||||
assert!(ime.expect("editing the other text").should_interrupt_composition);
|
||||
let ime = ime.expect("editing the other text");
|
||||
assert!(ime.should_interrupt_composition);
|
||||
let request_id = ime.reset_request.expect("the final destination publishes its reset request");
|
||||
assert!(app.ime_marked.is_none());
|
||||
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
|
||||
typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||||
@@ -8532,7 +8613,7 @@ mod tests {
|
||||
// A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks
|
||||
// for no IME, and the host tells the system IME to drop its composition.
|
||||
let blank = xf.to_screen(Point::new(150.0, 200.0));
|
||||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::ImeResetCompleted { request_id }, preedit("が", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]);
|
||||
let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]);
|
||||
assert!(plain(&app, other).contains('が'), "{}", plain(&app, other));
|
||||
@@ -8578,6 +8659,240 @@ mod tests {
|
||||
assert_eq!(plain(&app, id), "雅がくらくか");
|
||||
}
|
||||
|
||||
fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
|
||||
app.session.prefs.placeholder_text = false;
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
typing_frame(app, ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(app, ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||||
typing_frame(app, ctx, vec![click(at, false)]);
|
||||
at
|
||||
}
|
||||
|
||||
/// The outgoing descriptor carries the committed logical text and UTF-8 offsets, keeps a
|
||||
/// reversed selection reversed, and labels only accepted IME mutations `InputMethod`.
|
||||
#[test]
|
||||
fn ime_output_carries_text_offsets_and_explicit_cause() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
type_at(&mut app, &ctx);
|
||||
let empty = typing_frame(&mut app, &ctx, vec![]).expect("the Type tool owns the IME");
|
||||
let payload = empty.surrounding_text.available().expect("an empty document is available");
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0));
|
||||
assert_eq!(empty.change_cause, egui::output::ImeChangeCause::Other);
|
||||
|
||||
let typed = typing_frame(&mut app, &ctx, vec![egui::Event::Text("Héllo".into())]).unwrap();
|
||||
let payload = typed.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 6, 6));
|
||||
assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other, "ordinary typing is application-origin");
|
||||
|
||||
let shift_left =
|
||||
|| egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT };
|
||||
// "Héllo" has five characters: four Shift+Lefts stop at the start of its two-byte é,
|
||||
// leaving cursor 1 < anchor 6.
|
||||
let mut reversed = None;
|
||||
for _ in 0..4 {
|
||||
reversed = typing_frame(&mut app, &ctx, vec![shift_left()]);
|
||||
}
|
||||
let reversed = reversed.unwrap();
|
||||
let payload = reversed.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 1, 6), "cursor > anchor stays reversed");
|
||||
|
||||
// One accepted IME deletion (one byte before the selection) then the commit: both receipts
|
||||
// chain, and the commit still replaces the preserved selection ("éllo").
|
||||
let ime = typing_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![
|
||||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }),
|
||||
egui::Event::Ime(egui::ImeEvent::Commit("Z".into())),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let payload = ime.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Z", 1, 1));
|
||||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||||
|
||||
let ordinary = typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]).unwrap();
|
||||
assert_eq!(ordinary.change_cause, egui::output::ImeChangeCause::Other, "typing after a commit is Other");
|
||||
}
|
||||
|
||||
/// A pre-edit is excluded from the outgoing text; committing the composition is the accepted
|
||||
/// IME mutation that earns `InputMethod`.
|
||||
#[test]
|
||||
fn ime_output_excludes_the_preedit_and_marks_the_commit() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
type_at(&mut app, &ctx);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]);
|
||||
let marked = typing_frame(&mut app, &ctx, vec![preedit("xy", 2)]).unwrap();
|
||||
let payload = marked.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "the marked text is not committed text");
|
||||
let committed = typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("xy".into()))]).unwrap();
|
||||
let payload = committed.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abxy", 4, 4));
|
||||
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||||
}
|
||||
|
||||
/// An application edit anywhere in the frame dominates an IME receipt in it.
|
||||
#[test]
|
||||
fn ime_output_marks_mixed_application_and_ime_mutations_other() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
type_at(&mut app, &ctx);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]);
|
||||
let ime = typing_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![
|
||||
egui::Event::Text("x".into()),
|
||||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }),
|
||||
egui::Event::Ime(egui::ImeEvent::Commit("y".into())),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let payload = ime.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aby", 3, 3));
|
||||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other, "application edit before the IME dominates");
|
||||
|
||||
let pasted = typing_frame(&mut app, &ctx, vec![egui::Event::Paste("c".into())]).unwrap();
|
||||
let payload = pasted.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abyc", 4, 4));
|
||||
assert_eq!(pasted.change_cause, egui::output::ImeChangeCause::Other, "paste is application-origin");
|
||||
|
||||
// The other order: an accepted IME commit followed by an application edit in the same
|
||||
// frame is still `Other`.
|
||||
let ime_then_text =
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("y".into())), egui::Event::Text("x".into())]).unwrap();
|
||||
let payload = ime_then_text.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abycyx", 6, 6));
|
||||
assert_eq!(ime_then_text.change_cause, egui::output::ImeChangeCause::Other, "application edit after the IME dominates");
|
||||
}
|
||||
|
||||
/// Leaving and re-entering the same text object keeps the owner but starts a new generation.
|
||||
#[test]
|
||||
fn ime_owner_replacement_starts_a_new_generation() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.prefs.placeholder_text = false;
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Same", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
typing_frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center();
|
||||
let at = xf.to_screen(center);
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||||
let first = typing_frame(&mut app, &ctx, vec![]).expect("the text is being edited");
|
||||
assert_eq!(first.owner, egui::Id::unique(("vectorcraft-type-ime", app.session.active().unwrap().uid, id)));
|
||||
let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![escape(true)]);
|
||||
typing_frame(&mut app, &ctx, vec![escape(false)]);
|
||||
assert!(typing_frame(&mut app, &ctx, vec![]).is_none(), "Escape ends the edit context");
|
||||
app.select_tool("type");
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||||
let second = typing_frame(&mut app, &ctx, vec![]).expect("re-entering starts another context");
|
||||
assert_eq!(second.owner, first.owner, "the owner key is the document and text object");
|
||||
assert_ne!(second.context_generation, first.context_generation, "a replacement epoch resets once");
|
||||
}
|
||||
|
||||
/// One tracker serves several native viewports: each gets its own generation for the same
|
||||
/// editor, and an unchanged second pass in either keeps it.
|
||||
#[test]
|
||||
fn ime_context_is_scoped_per_viewport() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
type_at(&mut app, &ctx);
|
||||
let aux = egui::ViewportId::from_hash_of("vectorcraft-ime-aux");
|
||||
let frame = |app: &mut VectorcraftApp, viewport: egui::ViewportId| {
|
||||
let mut raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||||
viewport_id: viewport,
|
||||
..Default::default()
|
||||
};
|
||||
raw.viewports.entry(viewport).or_default();
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
show(app, ui);
|
||||
crate::ime::end_of_pass(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.ime.expect("the Type tool owns the IME in both viewports")
|
||||
};
|
||||
let root = egui::ViewportId::ROOT;
|
||||
let root_first = frame(&mut app, root);
|
||||
let aux_first = frame(&mut app, aux);
|
||||
assert_ne!(root_first.context_generation, aux_first.context_generation, "viewport state must not collide");
|
||||
let root_second = frame(&mut app, root);
|
||||
let aux_second = frame(&mut app, aux);
|
||||
assert_eq!(root_second.context_generation, root_first.context_generation);
|
||||
assert_eq!(aux_second.context_generation, aux_first.context_generation);
|
||||
}
|
||||
|
||||
/// A minimized/occluded viewport has no IME owner: the descriptor disappears, and the next
|
||||
/// visible pass starts a new generation instead of inheriting the old context.
|
||||
#[test]
|
||||
fn hidden_viewport_observes_no_ime_owner() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
type_at(&mut app, &ctx);
|
||||
let root = egui::ViewportId::ROOT;
|
||||
let visible = typing_frame(&mut app, &ctx, vec![]).unwrap();
|
||||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
raw.viewports.entry(root).or_default().minimized = Some(true);
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||||
show(&mut app, ui);
|
||||
crate::ime::end_of_pass(&mut app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(out.platform_output.ime.is_none(), "a hidden viewport has no IME owner");
|
||||
let again = typing_frame(&mut app, &ctx, vec![]).expect("the visible editor comes back");
|
||||
assert_ne!(again.context_generation, visible.context_generation, "refocus starts a new generation");
|
||||
}
|
||||
|
||||
/// A frame that is discarded and drawn again sees the mutation in both passes, and its
|
||||
/// unchanged second pass keeps the first pass's `InputMethod` cause and generation.
|
||||
#[test]
|
||||
fn ime_observation_runs_in_every_pass_of_a_discarded_frame() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
type_at(&mut app, &ctx);
|
||||
let mut first = true;
|
||||
let mut seen = vec![];
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
if first {
|
||||
first = false;
|
||||
let view = app.view_info();
|
||||
let target = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
app.session.tool_text("x", view).unwrap();
|
||||
crate::ime::note_ime_apply(&mut app, ui.ctx(), view, target, before);
|
||||
ui.ctx().request_discard("ime observation second pass");
|
||||
}
|
||||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||||
show(&mut app, ui);
|
||||
crate::ime::end_of_pass(&mut app, ui.ctx());
|
||||
seen.push(ui.ctx().output(|o| o.ime.clone()).expect("IME output in this pass"));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(seen.len(), 2, "the frame really ran two passes");
|
||||
assert_eq!(seen[0].context_generation, seen[1].context_generation, "the discarded pass is not a new context");
|
||||
assert_eq!(seen[0].change_cause, egui::output::ImeChangeCause::InputMethod);
|
||||
assert_eq!(seen[1].change_cause, egui::output::ImeChangeCause::InputMethod, "an unchanged pass retains the frame cause");
|
||||
let payload = seen[1].surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("x", 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_drag_moved_without_pointer_delta() {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -9230,9 +9545,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with
|
||||
/// their rects; none while it isn't shown. Icons, which draw as ⚠ without an image loader, are
|
||||
/// left out.
|
||||
/// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as
|
||||
/// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up.
|
||||
fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> {
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
frame(app, ctx, vec![]);
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
@@ -9241,7 +9557,7 @@ mod tests {
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) && t.galley.text() != "⚠" => {
|
||||
egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => {
|
||||
Some((t.galley.text().to_string(), t.visual_bounding_rect()))
|
||||
}
|
||||
_ => None,
|
||||
@@ -9711,12 +10027,12 @@ mod tests {
|
||||
show(app, ui);
|
||||
match cover {
|
||||
Some(Cover::Panel(r)) => {
|
||||
egui::Area::new(egui::Id::new("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| {
|
||||
egui::Area::new(egui::Id::unique("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| {
|
||||
ui.allocate_space(r.size());
|
||||
});
|
||||
}
|
||||
Some(Cover::Dialog) => {
|
||||
crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::new("test-dialog"), 0.0, 8, |ui| ui.label("A dialog"));
|
||||
crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
@@ -9995,6 +10311,7 @@ mod tests {
|
||||
delta: vec2(0.0, -30.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(&mut app, &ctx, vec![wheel]);
|
||||
|
||||
+108
-103
@@ -265,7 +265,7 @@ fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
};
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
|
||||
let resp = ui.interact(rect, egui::Id::new(ISOLATION_BUTTON), Sense::click());
|
||||
let resp = ui.interact(rect, egui::Id::unique(ISOLATION_BUTTON), Sense::click());
|
||||
if widgets::paint_icon_button(ui, resp, icon, tip, false).clicked() {
|
||||
crate::menus::invoke(app, id, json!({}));
|
||||
}
|
||||
@@ -489,7 +489,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
let is_active = Some(i) == 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.id().with(("tab", i)), Sense::click());
|
||||
let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click());
|
||||
if is_active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
} else if resp.hovered() {
|
||||
@@ -500,7 +500,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// (#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.id().with(("tabx", i)), Sense::click());
|
||||
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));
|
||||
@@ -515,7 +515,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// The » button: every open document, the active one checked.
|
||||
if overflow {
|
||||
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.id().with("tabs-more"), Sense::click());
|
||||
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"));
|
||||
@@ -563,10 +563,10 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) {
|
||||
crumbs.push(pn.clone());
|
||||
}
|
||||
let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0));
|
||||
ui.interact(bar, ui.id().with("iso-bar"), Sense::click_and_drag());
|
||||
ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::click_and_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.id().with("iso-back"), Sense::click());
|
||||
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 });
|
||||
ui.painter().text(
|
||||
bar.left_center() + vec2(30.0, 0.0),
|
||||
@@ -582,7 +582,7 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) {
|
||||
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.id().with(("pat-bar", cmd)), Sense::click());
|
||||
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);
|
||||
@@ -621,70 +621,70 @@ fn artboard_list(app: &VectorcraftApp, ui: &mut Ui, cur: usize) -> Option<usize>
|
||||
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.id().with("artboard-list");
|
||||
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);
|
||||
egui::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::WidgetType::SelectableLabel, true, i == cur, format!("{} {}", i + 1, board.name))
|
||||
});
|
||||
if resp.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
ui.name_inputs(tl!("Artboard"), |ui| {
|
||||
egui::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));
|
||||
}
|
||||
});
|
||||
if chosen.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
chosen
|
||||
})
|
||||
.inner
|
||||
.flatten()
|
||||
// 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
|
||||
}
|
||||
@@ -700,38 +700,42 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
|
||||
let print = app.session.prefs.display_print_size;
|
||||
let shown = crate::state::zoom_display_percent(zoom, print);
|
||||
egui::ComboBox::from_id_salt("zoom-combo")
|
||||
.selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5))
|
||||
.width(76.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for z in ZOOM_STOPS.iter().rev() {
|
||||
if ui
|
||||
.selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print))
|
||||
.clicked()
|
||||
{
|
||||
app.run("view.setZoom", json!({"zoom": z})).ok();
|
||||
ui.name_inputs(tl!("Zoom"), |ui| {
|
||||
egui::ComboBox::from_id_salt("zoom-combo")
|
||||
.selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5))
|
||||
.width(76.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for z in ZOOM_STOPS.iter().rev() {
|
||||
if ui
|
||||
.selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print))
|
||||
.clicked()
|
||||
{
|
||||
app.run("view.setZoom", json!({"zoom": z})).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(false, tl!("Fit on Screen")).clicked() {
|
||||
app.run("view.fitArtboard", json!({})).ok();
|
||||
}
|
||||
if ui.selectable_label(false, tl!("Fit All")).clicked() {
|
||||
app.run("view.fitAll", json!({})).ok();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.selectable_label(false, tl!("Fit on Screen")).clicked() {
|
||||
app.run("view.fitArtboard", json!({})).ok();
|
||||
}
|
||||
if ui.selectable_label(false, tl!("Fit All")).clicked() {
|
||||
app.run("view.fitAll", json!({})).ok();
|
||||
}
|
||||
});
|
||||
});
|
||||
let rot = app.view().map(|v| v.rotation).unwrap_or(0.0);
|
||||
egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (label, a) in ROTATE_VIEW_ANGLES {
|
||||
if ui.selectable_label((rot - a).abs() < 1e-9, label).clicked() {
|
||||
// It fails only without a document, when there is no view to turn.
|
||||
app.run("view.rotate", json!({"angle": a})).ok();
|
||||
ui.name_inputs(tl!("Rotation"), |ui| {
|
||||
egui::ComboBox::from_id_salt("rot-combo")
|
||||
.selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5))
|
||||
.width(52.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (label, a) in ROTATE_VIEW_ANGLES {
|
||||
if ui.selectable_label((rot - a).abs() < 1e-9, label).clicked() {
|
||||
// It fails only without a document, when there is no view to turn.
|
||||
app.run("view.rotate", json!({"angle": a})).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
// The artboard navigator has First and Previous, a field with the current artboard's
|
||||
// number and name and a list of every artboard, then Next and Last.
|
||||
@@ -779,7 +783,7 @@ struct StatusReadoutWidths {
|
||||
/// Reuse sample-string measurements until language, document units or display scale changes.
|
||||
/// Keep a single entry per UI so repeated DPI/zoom changes cannot grow the cache indefinitely.
|
||||
fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc::Unit, t: &Tokens) -> Arc<StatusReadoutWidths> {
|
||||
let id = ui.id().with("status-readout-widths");
|
||||
let id = ui.scope_id().with("status-readout-widths");
|
||||
let key = (lang, unit, ui.ctx().pixels_per_point());
|
||||
if let Some(cached) = ui.data(|data| data.get_temp::<Arc<StatusReadoutWidths>>(id))
|
||||
&& cached.key == key
|
||||
@@ -819,7 +823,7 @@ fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
||||
let [render_width, ui_width, fps_width, axis_width] = widths.values;
|
||||
let coord_width = 2.0 * axis_width;
|
||||
let perf_width = render_width + ui_width + fps_width + 12.0;
|
||||
let point_id = ui.id().with(("status-hover-point", app.session.active().map(|st| st.uid)));
|
||||
let point_id = ui.scope_id().with(("status-hover-point", app.session.active().map(|st| st.uid)));
|
||||
if let Some(p) = app.hover_doc {
|
||||
ui.data_mut(|data| data.insert_temp(point_id, p));
|
||||
}
|
||||
@@ -903,8 +907,8 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu
|
||||
let at = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
|
||||
let elided = galley.elided;
|
||||
ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color);
|
||||
let response = ui.interact(rect, ui.id().with(("status-field", id)), Sense::hover());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &text));
|
||||
let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text));
|
||||
if elided {
|
||||
response.on_hover_text(text);
|
||||
}
|
||||
@@ -1267,7 +1271,7 @@ mod tests {
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
status_readouts(app, ui, &t);
|
||||
cache = ui.data(|data| data.get_temp::<Arc<StatusReadoutWidths>>(ui.id().with("status-readout-widths")));
|
||||
cache = ui.data(|data| data.get_temp::<Arc<StatusReadoutWidths>>(ui.scope_id().with("status-readout-widths")));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
cache.expect("production status readouts populated their cache")
|
||||
@@ -1833,6 +1837,7 @@ mod tests {
|
||||
delta: egui::vec2(0.0, dy),
|
||||
phase,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
status_texts(
|
||||
&mut app,
|
||||
@@ -2030,7 +2035,7 @@ mod tests {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::control_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::new(super::ISOLATION_BUTTON)).map(|w| w.rect))
|
||||
ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::unique(super::ISOLATION_BUTTON)).map(|w| w.rect))
|
||||
}
|
||||
|
||||
/// Entering isolation mode leaves the canvas where it was: the breadcrumb bar is drawn over the
|
||||
@@ -2591,7 +2596,7 @@ mod visual_regressions {
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
let mut regions = serde_json::Map::new();
|
||||
for id in ["app_bar", "status_bar"] {
|
||||
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
|
||||
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::unique(id)).unwrap().outer_rect;
|
||||
regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()]));
|
||||
}
|
||||
let dynamic: Vec<_> = harness
|
||||
|
||||
@@ -337,7 +337,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
// Notches, not a flood: a turn stays within what a real wheel sends.
|
||||
let delta = egui::vec2(f("dx"), f("dy")).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0));
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
|
||||
app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers: modifiers(p) });
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit,
|
||||
delta,
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: modifiers(p),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
});
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
@@ -497,20 +503,59 @@ fn with_data(mut result: Value, png: Option<&[u8]>) -> Value {
|
||||
result
|
||||
}
|
||||
|
||||
/// The largest image whose PNG goes back in a `ui.screenshot` reply (`data: true`): the reply
|
||||
/// makes a straight-alpha copy, the encoded PNG and, for `data`, a base64 string (~1.33× the
|
||||
/// PNG), so this bounds that working set before any of it is allocated. A path-only capture keeps
|
||||
/// the shared 128 MiB image budget.
|
||||
pub(crate) const MAX_SCREENSHOT_PNG_REPLY_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// The checked RGBA size of `w × h`, when it fits.
|
||||
fn screenshot_rgba_bytes(w: usize, h: usize) -> Option<usize> {
|
||||
w.checked_mul(h).and_then(|n| n.checked_mul(4))
|
||||
}
|
||||
|
||||
/// The reply to `ui.screenshot`: the window capture `image` written to `path`, and sent back as
|
||||
/// `pngBase64` with `data`.
|
||||
pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>, data: bool) -> Value {
|
||||
save_screenshot_with_limit(app, image, path, data, MAX_SCREENSHOT_PNG_REPLY_BYTES)
|
||||
}
|
||||
|
||||
/// [`save_screenshot`] with the inline-PNG encoding limit supplied, so a small image can exercise
|
||||
/// the over-limit path without allocating a multi-megapixel frame. The public function always uses
|
||||
/// [`MAX_SCREENSHOT_PNG_REPLY_BYTES`].
|
||||
pub(crate) fn save_screenshot_with_limit(
|
||||
app: &mut VectorcraftApp,
|
||||
image: &egui::ColorImage,
|
||||
path: Option<&str>,
|
||||
data: bool,
|
||||
max_reply_bytes: usize,
|
||||
) -> Value {
|
||||
let [w, h] = image.size;
|
||||
if path.is_none() && !data {
|
||||
return json!({"ok": true, "result": {"width": w, "height": h}});
|
||||
}
|
||||
// A data-only reply has no second way to deliver the image, so refuse an oversized one before
|
||||
// allocating its working set. A request that named a path still writes that file: only the
|
||||
// inline copy is downgraded.
|
||||
if data && path.is_none() && !screenshot_rgba_bytes(w, h).is_some_and(|bytes| bytes <= max_reply_bytes) {
|
||||
return json!({"ok": false, "error": format!(
|
||||
"a PNG reply of this {w}×{h} screenshot would exceed the {max_reply_bytes}-byte reply-encoding limit; write it to a path instead"
|
||||
)});
|
||||
}
|
||||
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
let img = vectorcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba };
|
||||
let png = match img.to_png() {
|
||||
Ok(png) => png,
|
||||
Err(e) => return json!({"ok": false, "error": e}),
|
||||
};
|
||||
let data = data.then_some(png.as_slice());
|
||||
let reply_png = data && png.len() <= max_reply_bytes;
|
||||
if data && !reply_png && path.is_none() {
|
||||
return json!({"ok": false, "error": format!(
|
||||
"this screenshot's PNG is {} bytes, over the {max_reply_bytes}-byte reply-encoding limit; write it to a path instead",
|
||||
png.len()
|
||||
)});
|
||||
}
|
||||
let data = reply_png.then_some(png.as_slice());
|
||||
let Some(path) = path else {
|
||||
return json!({"ok": true, "result": with_data(json!({"width": w, "height": h}), data)});
|
||||
};
|
||||
|
||||
@@ -226,7 +226,7 @@ impl Default for View {
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(tl!("Show"));
|
||||
@@ -237,13 +237,15 @@ pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(tl!("Sort"));
|
||||
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
if ui.selectable_label(v.key == k, k.label().0).clicked() {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
ui.name_inputs(tl!("Sort"), |ui| {
|
||||
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
if ui.selectable_label(v.key == k, k.label().0).clicked() {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() {
|
||||
v.ascending = !v.ascending;
|
||||
|
||||
@@ -1156,7 +1156,7 @@ mod tests {
|
||||
let painted = |scale: f32| {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("probe"));
|
||||
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("probe"));
|
||||
assert!(paint(&ui.ctx().layer_painter(layer), Cursor::Arrow, pos2(100.0, 100.0), scale));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
|
||||
.show(ctx, |ui| {
|
||||
// Tabs: About · Contributors · Models (the credits are compiled in, see `credits`).
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, label) in [tl!("About"), tl!("Contributors"), tl!("Models")].into_iter().enumerate() {
|
||||
|
||||
@@ -86,15 +86,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let fills = fill_colors(app);
|
||||
let mut set = None;
|
||||
ui.horizontal(|ui| {
|
||||
form::row_label(ui, tl!("Background Color:"));
|
||||
if let Some(k) = crate::widgets::dropdown(ui, "ab-options-fill", fill_name(app, color), &FILLS, 110.0) {
|
||||
set = fills.get(k).copied();
|
||||
}
|
||||
let [r, g, b, _] = color.unwrap_or(Color::WHITE).to_rgba8(1.0);
|
||||
let mut rgb = [r, g, b];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
set = Some(Some(Color::rgb(f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0)));
|
||||
}
|
||||
let row = form::row_label(ui, tl!("Background Color:"));
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
if let Some(k) =
|
||||
crate::widgets::dropdown_named(ui, "ab-options-fill", Some(tl!("Background Color:")), fill_name(app, color), &FILLS, 110.0)
|
||||
{
|
||||
set = fills.get(k).copied();
|
||||
}
|
||||
let [r, g, b, _] = color.unwrap_or(Color::WHITE).to_rgba8(1.0);
|
||||
let mut rgb = [r, g, b];
|
||||
let swatch_name = format!("{} {}", tl!("Background Color:"), tl!("Swatches"));
|
||||
if ui.name_inputs(swatch_name, |ui| ui.color_edit_button_srgb(&mut rgb)).changed() {
|
||||
set = Some(Some(Color::rgb(f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0)));
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(c) = set {
|
||||
d.fields.insert("color".into(), c.as_ref().map_or(Value::Null, crate::panels::color_json));
|
||||
@@ -104,12 +109,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::check(ui, d, "showCrossHairs", tl!("Show Cross Hairs"));
|
||||
form::check(ui, d, "showVideoSafeAreas", tl!("Show Video Safe Areas"));
|
||||
ui.horizontal(|ui| {
|
||||
form::row_label(ui, tl!("Video Ruler Pixel Aspect Ratio:"));
|
||||
let range = vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE;
|
||||
let now = d.f64(PIXEL_ASPECT, 1.0);
|
||||
if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", PIXEL_ASPECT), now, range, "", 4, 60.0) {
|
||||
d.fields.insert(PIXEL_ASPECT.into(), json!(v));
|
||||
}
|
||||
let row = form::row_label(ui, tl!("Video Ruler Pixel Aspect Ratio:"));
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
let range = vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE;
|
||||
let now = d.f64(PIXEL_ASPECT, 1.0);
|
||||
if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", PIXEL_ASPECT), now, range, "", 4, 60.0) {
|
||||
d.fields.insert(PIXEL_ASPECT.into(), json!(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
false
|
||||
}
|
||||
@@ -159,7 +166,7 @@ fn fit_bounds(app: &mut VectorcraftApp, ctx: &egui::Context, selection: bool) ->
|
||||
}
|
||||
let st = app.session.active()?;
|
||||
let key = (st.uid, st.revision);
|
||||
let id = egui::Id::new("artboard-options-artwork-fit-bounds");
|
||||
let id = egui::Id::unique("artboard-options-artwork-fit-bounds");
|
||||
if let Some(cached) = ctx.data_mut(|data| data.get_temp::<ArtworkFitBounds>(id))
|
||||
&& cached.key == key
|
||||
{
|
||||
@@ -178,18 +185,21 @@ fn position_rows(ui: &mut egui::Ui, d: &mut Dialog, refi: usize, unit: Unit, ori
|
||||
let rp = vectorcraft_geom::reference_point(r, refi);
|
||||
let mut indent = 0.0;
|
||||
for (key, label, at, zero) in [("x", "X:", rp.x, origin.x), ("y", "Y:", rp.y, origin.y)] {
|
||||
ui.horizontal(|ui| {
|
||||
// Y's label lines up with X's, after the locator.
|
||||
if key == "x" {
|
||||
let left = ui.cursor().min.x;
|
||||
reference_locator(ui, refi);
|
||||
indent = ui.min_rect().right() - left;
|
||||
} else {
|
||||
ui.add_space(indent);
|
||||
}
|
||||
form::row_label(ui, label);
|
||||
});
|
||||
if let Some(v) = crate::widgets::num_field(ui, ("dlg-ab-xy", key), Some(at - zero), unit, form::FIELD_W) {
|
||||
let row = ui
|
||||
.horizontal(|ui| {
|
||||
// Y's label lines up with X's, after the locator.
|
||||
if key == "x" {
|
||||
let left = ui.cursor().min.x;
|
||||
reference_locator(ui, refi);
|
||||
indent = ui.min_rect().right() - left;
|
||||
} else {
|
||||
ui.add_space(indent);
|
||||
}
|
||||
form::row_label(ui, label)
|
||||
})
|
||||
.inner;
|
||||
let value = ui.label_inputs_by(row.id, |ui| crate::widgets::num_field(ui, ("dlg-ab-xy", key), Some(at - zero), unit, form::FIELD_W));
|
||||
if let Some(v) = value {
|
||||
let n = if key == "x" {
|
||||
artboard_rect(r, refi, Some(v + zero), None, None, None, false).x0
|
||||
} else {
|
||||
|
||||
@@ -125,7 +125,7 @@ pub(crate) fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool,
|
||||
let n = if along { vec2(-12.0 * x / 9.0, -1.0).normalized() } else { vec2(0.0, -1.0) };
|
||||
ui.painter().line_segment([p, p + n * 7.0], Stroke::new(2.0, color));
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, tip));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tip));
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
|
||||
@@ -439,7 +439,7 @@ fn tip_sample(ui: &mut egui::Ui, def: &Value, ty: &str, method: Colorization, na
|
||||
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, name));
|
||||
}
|
||||
|
||||
/// A document of `w` × `h` showing brush definition `def` (of type `ty`) with colorization
|
||||
|
||||
@@ -480,8 +480,8 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
|
||||
widgets::paint_chip(ui, top, &Paint::solid(*new));
|
||||
widgets::paint_chip(ui, bottom, &Paint::solid(*original));
|
||||
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||||
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text(tl!("New"));
|
||||
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text(tl!("Original: click to restore")).clicked()
|
||||
ui.interact(top, ui.scope_id().with("cp-new"), Sense::hover()).on_hover_text(tl!("New"));
|
||||
ui.interact(bottom, ui.scope_id().with("cp-original"), Sense::click()).on_hover_text(tl!("Original: click to restore")).clicked()
|
||||
}
|
||||
|
||||
/// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour
|
||||
@@ -518,7 +518,7 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, O
|
||||
1 | 2 => (p.hsb[i] * 100.0, "%"),
|
||||
_ => (rgb[i - 3] * 255.0, ""),
|
||||
};
|
||||
if let Some(nv) = widgets::plain_field(ui, ("cp-left", i), v as f64, suffix, 0, VALUE_W) {
|
||||
if let Some(nv) = widgets::plain_field_named(ui, ("cp-left", i), Some(ch.label()), v as f64, suffix, 0, VALUE_W) {
|
||||
let nv = nv as f32;
|
||||
edit = Some(match i {
|
||||
0..3 => {
|
||||
@@ -533,7 +533,9 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, O
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(c) = widgets::hex_field(ui, "cp-hex", &hex_digits(&p.color)).as_deref().and_then(parse_hex) {
|
||||
} else if let Some(c) =
|
||||
widgets::hex_field_named(ui, "cp-hex", Some(tl!("Color")), &hex_digits(&p.color)).as_deref().and_then(parse_hex)
|
||||
{
|
||||
edit = Some(Picked::of(c));
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -561,7 +563,7 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, O
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new(label).size(12.5).color(t.text));
|
||||
});
|
||||
if let Some(nv) = widgets::plain_field(ui, ("cp-right", i), v as f64, suffix, 0, VALUE_W) {
|
||||
if let Some(nv) = widgets::plain_field_named(ui, ("cp-right", i), Some(label), v as f64, suffix, 0, VALUE_W) {
|
||||
let nv = nv as f32;
|
||||
edit = Some(Picked::of(if i < 3 {
|
||||
let mut l = [lab.l, lab.a, lab.b];
|
||||
@@ -944,7 +946,8 @@ mod tests {
|
||||
assert_eq!(names[names.len() - 2..], [Some("Y"), Some("K")], "one Tab takes Y to K");
|
||||
let found: Vec<&(String, Rect)> = stops.iter().flatten().collect();
|
||||
for (left, right) in found[..7].iter().zip(&found[7..]) {
|
||||
assert_eq!(left.1.center().y, right.1.center().y, "{} and {} are level", left.0, right.0);
|
||||
// Two grids: their rows land on the same subpixel-rounded line, not necessarily bit-equal.
|
||||
assert!((left.1.center().y - right.1.center().y).abs() <= 0.5, "{} and {} are level", left.0, right.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,7 +1131,7 @@ mod tests {
|
||||
assert!((last_input.bottom() - field.bottom()).abs() < 2.0, "{last_input:?} vs {field:?}");
|
||||
// Fonts and translated actions may leave one normal gutter, but never an unused column.
|
||||
assert!((last_input.right() - ok.right()).abs() < COLUMN_GAP, "no unused column beside the inputs: {last_input:?} vs {ok:?}");
|
||||
let id = egui::Id::new(("dialog", KIND));
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
assert!(drawn.boxes.iter().all(|r| r.left() >= before.left() - 1.0 && r.right() <= before.right() + 1.0), "controls fit horizontally");
|
||||
let title = drawn.text(tl!("Color Picker"));
|
||||
@@ -1185,7 +1188,7 @@ mod tests {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color":"#ffffff"})).unwrap();
|
||||
settled(&ctx, &mut app, screen());
|
||||
let id = egui::Id::new(("dialog", KIND));
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000FE"));
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
@@ -1217,7 +1220,7 @@ mod tests {
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, screen());
|
||||
let title = drawn.text(tl!("Color Picker")).center();
|
||||
let id = egui::Id::new(("dialog", KIND));
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let before = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title), pointer(title, true)]);
|
||||
@@ -1273,7 +1276,7 @@ mod tests {
|
||||
let (ctx, mut app) = (context(), app());
|
||||
app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap();
|
||||
let drawn = settled(&ctx, &mut app, size);
|
||||
let outer = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", KIND)))).unwrap();
|
||||
let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap();
|
||||
assert!(outer.width() <= size.x && outer.height() <= size.y, "{outer:?} in {size:?}");
|
||||
let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap();
|
||||
assert!((MIN_FIELD..=FIELD).contains(&field.width()));
|
||||
@@ -1295,6 +1298,7 @@ mod tests {
|
||||
delta: vec2(0.0, -20.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@@ -100,10 +100,12 @@ fn precision(ui: &mut Ui, fields: &mut serde_json::Map<String, Value>, id: &str)
|
||||
let before = fields.get("precision").and_then(Value::as_u64).unwrap_or(2) as u32;
|
||||
let mut value = before;
|
||||
ui.label(tl!("Precision:"));
|
||||
egui::ComboBox::from_id_salt(id).selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| {
|
||||
for n in 0..=6 {
|
||||
ui.selectable_value(&mut value, n, n.to_string());
|
||||
}
|
||||
ui.name_inputs(tl!("Precision"), |ui| {
|
||||
egui::ComboBox::from_id_salt(id).selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| {
|
||||
for n in 0..=6 {
|
||||
ui.selectable_value(&mut value, n, n.to_string());
|
||||
}
|
||||
});
|
||||
});
|
||||
fields.insert("precision".into(), json!(value));
|
||||
value != before
|
||||
@@ -129,15 +131,18 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label_row(ui, tl!("Scale:"), 96.0, |ui| {
|
||||
let mut scale = f.get("scale").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
if ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0)).changed() {
|
||||
let changed = ui.name_inputs(tl!("Scale"), |ui| ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0))).changed();
|
||||
if changed {
|
||||
f.insert("scale".into(), json!(scale));
|
||||
}
|
||||
egui::ComboBox::from_id_salt("dimension-scales").selected_text(tl!("Presets")).width(76.0).show_ui(ui, |ui| {
|
||||
for scale in ["1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "1:200", "2:1", "5:1", "10:1"] {
|
||||
if ui.selectable_label(false, scale).clicked() {
|
||||
f.insert("scale".into(), json!(scale));
|
||||
ui.name_inputs(tl!("Scale Presets"), |ui| {
|
||||
egui::ComboBox::from_id_salt("dimension-scales").selected_text(tl!("Presets")).width(76.0).show_ui(ui, |ui| {
|
||||
for scale in ["1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "1:200", "2:1", "5:1", "10:1"] {
|
||||
if ui.selectable_label(false, scale).clicked() {
|
||||
f.insert("scale".into(), json!(scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
check(ui, f, "hideUnits", "Hide Units");
|
||||
@@ -251,7 +256,7 @@ pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
egui::Window::new(tl!("Dimension Tool"))
|
||||
.id(egui::Id::new("dimension-strip"))
|
||||
.id(egui::Id::unique("dimension-strip"))
|
||||
.default_pos(egui::pos2(90.0, 120.0))
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
|
||||
@@ -133,7 +133,8 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.painter().rect_filled(chip, 2.0, crate::panels::c32(&c));
|
||||
}
|
||||
ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
if let Some(c) = widgets::hex_field(ui, ("grid-color", i), hex).and_then(|h| vectorcraft_color::Color::from_hex(&h)) {
|
||||
let name = format!("{} {}", tl!("Grid Colors:"), i + 1);
|
||||
if let Some(c) = widgets::hex_field_named(ui, ("grid-color", i), Some(&name), hex).and_then(|h| vectorcraft_color::Color::from_hex(&h)) {
|
||||
let mut v = colors.clone();
|
||||
v.resize(2, "#ffffff".into());
|
||||
v[i] = c.to_hex();
|
||||
|
||||
@@ -163,7 +163,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog) -> Option<egui::TextureHandle> {
|
||||
let file = app.ui.dialog_file.clone()?;
|
||||
let layout = d.str("layout");
|
||||
let key = egui::Id::new(("dxf-preview", file.token, layout.as_str()));
|
||||
let key = egui::Id::unique(("dxf-preview", file.token, layout.as_str()));
|
||||
let o = vectorcraft_cad::ImportOptions { layout: Some(layout.clone()), fit: true, ..Default::default() };
|
||||
// A layout that can't be read shows an empty preview (and isn't read again).
|
||||
preview_texture(app, ctx, key, "dxf-preview", PREVIEW, || vectorcraft_cad::import(&file.bytes, &o).ok().map(|r| r.document))
|
||||
|
||||
@@ -611,7 +611,7 @@ fn curve_graph(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::click_and_drag());
|
||||
let screen = |(x, y): (f32, f32)| egui::pos2(rect.left() + x * SIZE, rect.bottom() - y * SIZE);
|
||||
let unit = |p: egui::Pos2| (((p.x - rect.left()) / SIZE).clamp(0.0, 1.0), ((rect.bottom() - p.y) / SIZE).clamp(0.0, 1.0));
|
||||
let held = ui.id().with("curve-point");
|
||||
let held = ui.scope_id().with("curve-point");
|
||||
let mut grabbed: Option<usize> = ui.data(|m| m.get_temp(held)).flatten();
|
||||
let mut changed = false;
|
||||
if let Some(p) = resp.interact_pointer_pos()
|
||||
|
||||
@@ -467,7 +467,7 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
set_select(d, "range");
|
||||
}
|
||||
ui.add_enabled_ui(select == "range", |ui| {
|
||||
if form::text(ui, d, "range", 90.0)
|
||||
if form::text_named(ui, d, "range", tl!("Range:"), 90.0)
|
||||
&& let Ok(Some(chosen)) = (ArtboardPick { range: Some(d.str("range")), ..Default::default() }).resolve(n)
|
||||
{
|
||||
boards = (0..n).map(|i| chosen.contains(&i)).collect();
|
||||
@@ -705,7 +705,9 @@ fn naming(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
if form::text(ui, d, "name", 170.0) {
|
||||
d.fields.insert("exportPreset".into(), json!(""));
|
||||
}
|
||||
if let Some((_, token)) = widgets::dropdown(ui, "efs-name-token", tl!("Insert"), &TOKEN_LABELS, 90.0).and_then(|i| TOKENS.get(i)) {
|
||||
if let Some((_, token)) =
|
||||
widgets::dropdown_named(ui, "efs-name-token", Some(tl!("Insert")), tl!("Insert"), &TOKEN_LABELS, 90.0).and_then(|i| TOKENS.get(i))
|
||||
{
|
||||
let name = d.str("name");
|
||||
d.fields.insert("name".into(), json!(format!("{name}{token}")));
|
||||
}
|
||||
@@ -817,7 +819,9 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let preset = d.str("preset");
|
||||
let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect();
|
||||
let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1);
|
||||
if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0)) {
|
||||
if let Some(i) =
|
||||
widgets::dropdown_named(ui, "efs-preset", Some(tl!("Preset:")), labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0))
|
||||
{
|
||||
let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id);
|
||||
if let Some(preset_rows) = fileio::screen_preset_rows(id) {
|
||||
rows = preset_rows;
|
||||
@@ -843,7 +847,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster);
|
||||
let scale = row["scale"].as_str().unwrap_or("1x").to_string();
|
||||
ui.add_enabled_ui(raster, |ui| {
|
||||
if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, scale_w) {
|
||||
if let Some(s) = widgets::text_presets_named(ui, ("efs-scale", i), Some(tl!("Scale")), &scale, &SIZES, scale_w) {
|
||||
// The suffix follows the size while it is the automatic one.
|
||||
let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix);
|
||||
if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale))
|
||||
@@ -855,7 +859,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
edited = true;
|
||||
}
|
||||
});
|
||||
if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), suffix_w, 1) {
|
||||
if let Some(s) = widgets::text_field_named(ui, ("efs-suffix", i), Some(tl!("Suffix")), row["suffix"].as_str(), suffix_w, 1) {
|
||||
row["suffix"] = json!(s);
|
||||
edited = true;
|
||||
}
|
||||
@@ -864,7 +868,9 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let current =
|
||||
FORMATS.iter().position(|(_, f, q, print)| *f == format && *print == marked && q.is_none_or(|q| Some(u64::from(q)) == quality));
|
||||
let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string());
|
||||
if let Some((_, f, q, print)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, format_w).and_then(|i| FORMATS.get(i)) {
|
||||
if let Some((_, f, q, print)) =
|
||||
widgets::dropdown_named(ui, ("efs-fmt", i), Some(tl!("Format")), &label, &FORMAT_LABELS, format_w).and_then(|i| FORMATS.get(i))
|
||||
{
|
||||
row["format"] = json!(f);
|
||||
if let Some(o) = row.as_object_mut() {
|
||||
match q {
|
||||
@@ -921,7 +927,9 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
|
||||
widgets::subheader(ui, tl!("Format Settings"));
|
||||
ui.add_space(6.0);
|
||||
let at = SETTINGS.iter().position(|(f, _)| *f == id).unwrap_or(0);
|
||||
if let Some((f, _)) = widgets::dropdown(ui, "efs-settings-format", SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0).and_then(|i| SETTINGS.get(i)) {
|
||||
if let Some((f, _)) = widgets::dropdown_named(ui, "efs-settings-format", Some(tl!("Format:")), SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0)
|
||||
.and_then(|i| SETTINGS.get(i))
|
||||
{
|
||||
d.fields.insert("__settings".into(), json!(f));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -958,7 +966,7 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
|
||||
/// A small cached rendering of artboard `i` (keyed by document revision).
|
||||
pub(super) fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option<egui::TextureHandle> {
|
||||
let st = app.session.active()?;
|
||||
let key = egui::Id::new(("ab-thumb", i, st.revision));
|
||||
let key = egui::Id::unique(("ab-thumb", i, st.revision));
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
|
||||
return Some(t);
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ fn chips(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.painter().galley(egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, t.text_strong);
|
||||
let x = egui::Rect::from_center_size(egui::pos2(rect.right() - 11.0, rect.center().y), egui::vec2(14.0, 14.0));
|
||||
let resp = ui
|
||||
.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click())
|
||||
.interact(x, ui.scope_id().with(("keyword-remove", i)), egui::Sense::click())
|
||||
.on_hover_text(crate::i18n::fmt(tl!("Remove “{keyword}”"), &[("keyword", k.as_str())]));
|
||||
icons::paint(ui, "x", x.shrink(2.0), if resp.hovered() { t.text_strong } else { t.text_dim });
|
||||
if resp.clicked() {
|
||||
|
||||
@@ -205,7 +205,9 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
|
||||
o.balance = b;
|
||||
}
|
||||
dim(ui, tl!("Vectors"));
|
||||
if let Some(v) = widgets::range_field(ui, (id, "balance"), o.balance, 0.0..=100.0, "", 0, FIELD - 12.0) {
|
||||
if let Some(v) =
|
||||
widgets::range_field_named(ui, (id, "balance"), Some(tl!("Raster/Vector Balance:")), o.balance, 0.0..=100.0, "", 0, FIELD - 12.0)
|
||||
{
|
||||
o.balance = v;
|
||||
}
|
||||
});
|
||||
@@ -215,7 +217,7 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
|
||||
widgets::dim_label(ui, label);
|
||||
ui.horizontal(|ui| {
|
||||
let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi };
|
||||
if let Some(n) = widgets::range_field(ui, (id, key), *v, 1.0..=2400.0, "", 0, FIELD) {
|
||||
if let Some(n) = widgets::range_field_named(ui, (id, key), Some(label), *v, 1.0..=2400.0, "", 0, FIELD) {
|
||||
*v = n;
|
||||
}
|
||||
dim(ui, "ppi");
|
||||
|
||||
@@ -18,21 +18,25 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
|
||||
/// [`field`] `width` points wide.
|
||||
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
|
||||
row_label(ui, label);
|
||||
text(ui, d, key, width);
|
||||
let row = row_label(ui, label);
|
||||
// The value widget must be named by the real Label already drawn beside it (egui 0.37's
|
||||
// kittest fails an unnamed input).
|
||||
ui.label_inputs_by(row.id, |ui| text(ui, d, key, width));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// The label cell of a grid row.
|
||||
pub(super) fn row_label(ui: &mut egui::Ui, label: &str) {
|
||||
/// The label cell of a grid row (its `Response`, so the value can be named by it).
|
||||
pub(super) fn row_label(ui: &mut egui::Ui, label: &str) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim))
|
||||
}
|
||||
|
||||
/// A labelled [`length`] field (one grid row).
|
||||
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
|
||||
row_label(ui, label);
|
||||
length(ui, d, key, unit, FIELD_W);
|
||||
let row = row_label(ui, label);
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
length(ui, d, key, unit, FIELD_W);
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
@@ -137,18 +141,38 @@ pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) ->
|
||||
text_edit(ui, d, key, width).changed()
|
||||
}
|
||||
|
||||
/// [`text`] for a row whose visible meaning is not the immediately preceding label widget
|
||||
/// (Export for Screens' Range field follows its radio buttons): screen readers name the field
|
||||
/// `name`, already translated. The pending label is still consumed, so it cannot name a later
|
||||
/// field.
|
||||
pub(super) fn text_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, width: f32) -> bool {
|
||||
text_edit_named(ui, d, key, name, width).changed()
|
||||
}
|
||||
|
||||
/// [`text`] returning the field's response (focus, Enter).
|
||||
pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
|
||||
text_edit_limited(ui, d, key, width, usize::MAX)
|
||||
}
|
||||
|
||||
/// [`text_edit`] with an explicit translated name (see [`text_named`]).
|
||||
pub(super) fn text_edit_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, width: f32) -> egui::Response {
|
||||
text_edit_limited_name(ui, d, key, width, usize::MAX, Some(name))
|
||||
}
|
||||
|
||||
/// [`text_edit`] that takes at most `max_chars` characters: typing and pasting stop at the limit,
|
||||
/// and a longer value already in the field is kept, with nothing added to it until it has fewer
|
||||
/// than `max_chars` characters.
|
||||
pub(super) fn text_edit_limited(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, max_chars: usize) -> egui::Response {
|
||||
text_edit_limited_name(ui, d, key, width, max_chars, None)
|
||||
}
|
||||
|
||||
/// The text field shared by [`text_edit_limited`] and [`text_edit_named`]: `name` wins over the
|
||||
/// adjacent label, and either way the pending label is taken so it can't leak to a later field.
|
||||
fn text_edit_limited_name(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, max_chars: usize, name: Option<&str>) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = crate::widgets::take_previous_label(ui);
|
||||
let mut s = d.str(key);
|
||||
let id = ui.id().with(("dlg-text", key));
|
||||
let id = ui.scope_id().with(("dlg-text", key));
|
||||
crate::widgets::take_dialog_focus(ui, id, &s);
|
||||
let r = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
@@ -162,13 +186,23 @@ pub(super) fn text_edit_limited(ui: &mut egui::Ui, d: &mut Dialog, key: &str, wi
|
||||
if r.inner.changed() {
|
||||
d.fields.insert(key.into(), Value::String(s));
|
||||
}
|
||||
r.inner
|
||||
match name {
|
||||
Some(name) => r.inner.accessible_name(name),
|
||||
None => crate::widgets::label_field(r.inner, label),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text box `rows` lines tall and `width` wide bound to `d.fields[key]`. Enter adds a line (it
|
||||
/// doesn't press OK while the box has focus).
|
||||
pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, rows: usize) -> egui::Response {
|
||||
text_area_named(ui, d, key, None, width, rows)
|
||||
}
|
||||
|
||||
/// [`text_area`] with an optional explicit translated name; the pending adjacent label is consumed
|
||||
/// either way.
|
||||
pub(super) fn text_area_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: Option<&str>, width: f32, rows: usize) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = crate::widgets::take_previous_label(ui);
|
||||
let mut s = d.str(key);
|
||||
let r = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
@@ -185,7 +219,10 @@ pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32
|
||||
if r.inner.has_focus() {
|
||||
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
}
|
||||
r.inner
|
||||
match name {
|
||||
Some(name) => r.inner.accessible_name(name),
|
||||
None => crate::widgets::label_field(r.inner, label),
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkbox bound to `d.fields[key]` (its box shows while unchecked too).
|
||||
@@ -246,10 +283,12 @@ pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: imp
|
||||
if lengths.contains(&k.as_str()) {
|
||||
length_field(ui, d, &k, &humanized(&k), unit);
|
||||
} else if let Some(suffix) = suffix(&d.kind, &k) {
|
||||
row_label(ui, &humanized(&k));
|
||||
if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", &k), d.f64(&k, 0.0), suffix, 3, FIELD_W) {
|
||||
d.fields.insert(k, json!(v));
|
||||
}
|
||||
let row = row_label(ui, &humanized(&k));
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", &k), d.f64(&k, 0.0), suffix, 3, FIELD_W) {
|
||||
d.fields.insert(k, json!(v));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
} else if let Some(ends) = skew_ends(&d.kind, &k) {
|
||||
let max = vectorcraft_geom::shapes::GRID_SKEW_MAX;
|
||||
@@ -269,8 +308,8 @@ pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: imp
|
||||
};
|
||||
field(ui, d, &k, &label);
|
||||
} else {
|
||||
row_label(ui, &humanized(&k));
|
||||
dropdown(ui, d, &k, FIELD_W, &options, true);
|
||||
let row = row_label(ui, &humanized(&k));
|
||||
ui.label_inputs_by(row.id, |ui| dropdown(ui, d, &k, FIELD_W, &options, true));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
@@ -307,71 +346,74 @@ pub(super) fn param_fields(
|
||||
for (k, v) in keys {
|
||||
// A size's Relative / Absolute pair (Roughen, Zig Zag, Tweak) is its own label.
|
||||
let relative = k == "relative" && v.is_boolean();
|
||||
ui.label(egui::RichText::new(if relative { String::new() } else { humanized(&k) }).color(t.text));
|
||||
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
|
||||
if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) {
|
||||
d.fields.insert(k, m);
|
||||
changed = true;
|
||||
}
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
if is_length(&k) {
|
||||
changed |= length(ui, d, &k, unit, 140.0);
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
if let Some(options) = choices(&k) {
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| choice_label(l));
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| choice_label(l)).collect();
|
||||
if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(k, json!(value));
|
||||
changed = true;
|
||||
}
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) {
|
||||
d.fields.insert(k, typed_number(x));
|
||||
let row = ui.label(egui::RichText::new(if relative { String::new() } else { humanized(&k) }).color(t.text));
|
||||
// The row's actual label node names every input beside it, including plain
|
||||
// `TextEdit`s and the numeric field beside a slider (egui 0.37's strict a11y gate).
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
|
||||
if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) {
|
||||
d.fields.insert(k.clone(), m);
|
||||
changed = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Value::Bool(b) if relative => {
|
||||
ui.horizontal(|ui| {
|
||||
for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] {
|
||||
if crate::widgets::radio(ui, label, b == on, true) && b != on {
|
||||
d.fields.insert(k.clone(), json!(on));
|
||||
changed = true;
|
||||
}
|
||||
if is_length(&k) {
|
||||
changed |= length(ui, d, &k, unit, 140.0);
|
||||
return;
|
||||
}
|
||||
if let Some(options) = choices(&k) {
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| choice_label(l));
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| choice_label(l)).collect();
|
||||
if let Some((_, value)) =
|
||||
crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i))
|
||||
{
|
||||
d.fields.insert(k.clone(), json!(value));
|
||||
changed = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) {
|
||||
d.fields.insert(k.clone(), typed_number(x));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
if crate::widgets::check_box(ui, &humanized(&k), b, true) {
|
||||
d.fields.insert(k, json!(!b));
|
||||
changed = true;
|
||||
}
|
||||
Value::Bool(b) if relative => {
|
||||
ui.horizontal(|ui| {
|
||||
for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] {
|
||||
if crate::widgets::radio(ui, label, b == on, true) && b != on {
|
||||
d.fields.insert(k.clone(), json!(on));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
if crate::widgets::check_box(ui, &humanized(&k), b, true) {
|
||||
d.fields.insert(k.clone(), json!(!b));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
Value::String(mut s) if s.contains('\n') => {
|
||||
// Multi-line values (Graph Data CSV) get a text area.
|
||||
if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() {
|
||||
d.fields.insert(k.clone(), json!(s));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
Value::String(mut s) => {
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
|
||||
d.fields.insert(k.clone(), json!(s));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
other => {
|
||||
ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
Value::String(mut s) if s.contains('\n') => {
|
||||
// Multi-line values (Graph Data CSV) get a text area.
|
||||
if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() {
|
||||
d.fields.insert(k, json!(s));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
Value::String(mut s) => {
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
|
||||
d.fields.insert(k, json!(s));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
other => {
|
||||
ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
@@ -462,16 +504,16 @@ fn param_row(ui: &mut egui::Ui, d: &mut Dialog, k: &str, p: &Param) -> (bool, Op
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = param_label(k, p);
|
||||
let shown_label = ui.label(egui::RichText::new(&label).color(t.text));
|
||||
if let Some(help) = &p.ui.help {
|
||||
shown_label.on_hover_text(help);
|
||||
}
|
||||
let shown_label = if let Some(help) = &p.ui.help { shown_label.on_hover_text(help) } else { shown_label };
|
||||
let cur = d.fields.get(k).cloned().unwrap_or_else(|| p.spec.default_value());
|
||||
let unit = p.ui.unit.as_deref().unwrap_or_default();
|
||||
let suffix = unit_suffix(unit);
|
||||
let mut opened = None;
|
||||
let new = ui
|
||||
.horizontal(|ui| {
|
||||
let (new, in_field) = value_field(ui, k, &label, &p.spec, &cur, &suffix);
|
||||
// The row's painted label names every input in its value column (egui 0.37's strict
|
||||
// accessibility gate), not only the first one.
|
||||
let (new, in_field) = ui.label_inputs_by(shown_label.id, |ui| value_field(ui, k, &label, &p.spec, &cur, &suffix));
|
||||
if !in_field && !unit.is_empty() {
|
||||
ui.label(egui::RichText::new(unit).color(t.text_dim));
|
||||
}
|
||||
@@ -494,7 +536,7 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &
|
||||
match spec {
|
||||
ParamSpec::Number { min, max, .. } => {
|
||||
let x = cur.as_f64().unwrap_or(*min);
|
||||
(crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, suffix, 2, 100.0).map(|x| json!(x)), true)
|
||||
(crate::widgets::range_field_named(ui, ("plugin-num", k), Some(label), x, *min..=*max, suffix, 2, 100.0).map(|x| json!(x)), true)
|
||||
}
|
||||
ParamSpec::Int { min, max, .. } => {
|
||||
let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
|
||||
@@ -502,12 +544,13 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &
|
||||
// Past it, the number typed is read as an integer.
|
||||
const EXACT: i64 = 1 << 53;
|
||||
if -EXACT <= *min && *max <= EXACT {
|
||||
let field = crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, suffix, 0, 100.0);
|
||||
let field =
|
||||
crate::widgets::range_field_named(ui, ("plugin-int", k), Some(label), x as f64, *min as f64..=*max as f64, suffix, 0, 100.0);
|
||||
(field.map(|x| json!(x as i64)), true)
|
||||
} else {
|
||||
let shown = x.to_string();
|
||||
crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown);
|
||||
let typed = crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1);
|
||||
crate::widgets::take_dialog_focus(ui, ui.scope_id().with(("plugin-int", k)), &shown);
|
||||
let typed = crate::widgets::text_field_named(ui, ("plugin-int", k), Some(label), Some(&shown), 100.0, 1);
|
||||
(typed.and_then(|text| text.parse::<i64>().ok()).map(|x| json!(x.clamp(*min, *max))), false)
|
||||
}
|
||||
}
|
||||
@@ -517,15 +560,16 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &
|
||||
}
|
||||
ParamSpec::Choice { options, .. } => {
|
||||
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
|
||||
let chosen = crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0);
|
||||
let chosen =
|
||||
crate::widgets::dropdown_names_named(ui, ("plugin-choice", k), Some(label), cur.as_str().unwrap_or_default(), &labels, 140.0);
|
||||
(chosen.and_then(|i| options.get(i)).map(|o| json!(o)), false)
|
||||
}
|
||||
ParamSpec::Text { max_length, .. } => {
|
||||
let typed = crate::widgets::text_field(ui, ("plugin-text", k), cur.as_str(), 160.0, 1);
|
||||
let typed = crate::widgets::text_field_named(ui, ("plugin-text", k), Some(label), cur.as_str(), 160.0, 1);
|
||||
(typed.map(|text| json!(text.chars().take(*max_length).collect::<String>())), false)
|
||||
}
|
||||
ParamSpec::NumberList { .. } => {
|
||||
let typed = crate::widgets::text_field(ui, ("plugin-list", k), Some(&number_list_text(cur)), 160.0, 1);
|
||||
let typed = crate::widgets::text_field_named(ui, ("plugin-list", k), Some(label), Some(&number_list_text(cur)), 160.0, 1);
|
||||
(typed.and_then(|text| read_number_list(&text, spec)), false)
|
||||
}
|
||||
}
|
||||
@@ -656,7 +700,7 @@ pub(super) fn slider_field_named(
|
||||
let v = d.f64(key, 0.0).clamp(min, max);
|
||||
let shown = crate::widgets::SliderValue::field(v, range.clone(), suffix, 0);
|
||||
slider_rail_valued(ui, d, key, name, range, Some(shown), track);
|
||||
if let Some(n) = crate::widgets::range_field(ui, ("dlg-field", key), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) {
|
||||
if let Some(n) = crate::widgets::range_field_named(ui, ("dlg-field", key), Some(name), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) {
|
||||
d.fields.insert(key.into(), json!(n));
|
||||
}
|
||||
}
|
||||
@@ -823,8 +867,9 @@ pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
for (i, side) in ["Top", "Bottom", "Left", "Right"].into_iter().enumerate() {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!(side)).size(11.0).color(t.text_dim));
|
||||
if let Some(v) = crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width) {
|
||||
let label = ui.label(egui::RichText::new(tl!(side)).size(11.0).color(t.text_dim));
|
||||
let v = ui.label_inputs_by(label.id, |ui| crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width));
|
||||
if let Some(v) = v {
|
||||
changed = Some((i, v.clamp(0.0, vectorcraft_doc::setup::MAX_BLEED)));
|
||||
}
|
||||
});
|
||||
@@ -914,7 +959,7 @@ mod selected_text_tests {
|
||||
let mut d = Dialog::new("test", json!({"name": "Artboard 1"}));
|
||||
let id = std::cell::Cell::new(egui::Id::NULL);
|
||||
let mut add = |ui: &mut egui::Ui| {
|
||||
id.set(ui.id().with(("dlg-text", "name")));
|
||||
id.set(ui.scope_id().with(("dlg-text", "name")));
|
||||
text_edit(ui, &mut d, "name", 120.0);
|
||||
};
|
||||
frame(&ctx, 0.0, vec![], &mut add);
|
||||
|
||||
@@ -87,10 +87,10 @@ pub(super) fn fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Per handle: whether it is held or hovered, and whether it has the keyboard focus.
|
||||
let mut states = [(false, false); 4];
|
||||
for (i, corner) in c.iter_mut().enumerate() {
|
||||
let id = ui.id().with(("free-distort-corner", i));
|
||||
let id = ui.scope_id().with(("free-distort-corner", i));
|
||||
let at = pad.clamp(to_screen(*corner));
|
||||
let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(GRAB)), id, Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), names[i]));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), names[i]));
|
||||
if resp.drag_started() || resp.clicked() {
|
||||
resp.request_focus();
|
||||
}
|
||||
@@ -132,7 +132,7 @@ pub(super) fn fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let reset = ui
|
||||
.add_enabled_ui(c != IDENTITY, |ui| {
|
||||
let r = widgets::flat_button(ui, tl!("Reset"), 64.0);
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset")));
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset")));
|
||||
r
|
||||
})
|
||||
.inner;
|
||||
|
||||
@@ -90,7 +90,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let screen =
|
||||
d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
|
||||
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
|
||||
let area = egui::Id::new("gradient-stop-popover");
|
||||
let area = egui::Id::unique("gradient-stop-popover");
|
||||
let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size());
|
||||
let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) {
|
||||
Some((canvas, v)) => {
|
||||
|
||||
@@ -337,7 +337,7 @@ fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let entry_id = egui::Id::new("graph-data-entry");
|
||||
let entry_id = egui::Id::unique("graph-data-entry");
|
||||
if ui.memory(|m| m.has_focus(entry_id)) {
|
||||
// Tab, Enter (Shift: the other way) and the arrow keys move the picked cell while the entry box
|
||||
// has the keys; the entry keeps them (egui would move the focus on with Tab).
|
||||
|
||||
@@ -282,7 +282,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
if widgets::radio(ui, tl!("Range:"), !all, true) {
|
||||
d.fields.insert("allPages".into(), json!(false));
|
||||
}
|
||||
if super::form::text_edit(ui, d, "range", 90.0).gained_focus() {
|
||||
if super::form::text_edit_named(ui, d, "range", tl!("Range:"), 90.0).gained_focus() {
|
||||
d.fields.insert("allPages".into(), json!(false));
|
||||
}
|
||||
});
|
||||
@@ -311,7 +311,7 @@ fn password_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usize) -> Option<egui::TextureHandle> {
|
||||
let file = app.ui.dialog_file.clone()?;
|
||||
let crop = crop(d);
|
||||
let key = egui::Id::new(("pdf-preview", file.token, page, crop.id()));
|
||||
let key = egui::Id::unique(("pdf-preview", file.token, page, crop.id()));
|
||||
let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() };
|
||||
// A page that can't be read shows an empty preview (and isn't read again).
|
||||
preview_texture(app, ctx, key, &format!("pdf-preview-{page}"), PREVIEW, || {
|
||||
|
||||
@@ -125,7 +125,7 @@ fn color_dropdown(ui: &mut egui::Ui, shown: &str) -> Option<usize> {
|
||||
widgets::combo(ui, "layer-color", tl!(shown), 140.0, false, |ui| {
|
||||
let mut chosen = None;
|
||||
for (i, (name, rgb)) in rows.enumerate() {
|
||||
let chip = ui.id().with(("layer-color-chip", i));
|
||||
let chip = egui::IdSalt::new(("layer-color-chip", i));
|
||||
let r = egui::Button::selectable(name == shown, (egui::Atom::custom(chip, CHIP), tl!(name))).atom_ui(ui);
|
||||
if let (Some(rect), Some([cr, cg, cb])) = (r.rect(chip), rgb) {
|
||||
ui.painter().rect_filled(rect, 1.0, egui::Color32::from_rgb(cr, cg, cb));
|
||||
|
||||
@@ -448,7 +448,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
confirm::show_waiting(app);
|
||||
// The kind of dialog shown last frame: a different one (or none) means this one just opened, and
|
||||
// its first field is to take the keyboard focus (`focus_id`, until a field takes it).
|
||||
let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending"));
|
||||
let (shown_id, focus_id) = (egui::Id::unique("dialog-shown"), egui::Id::unique("dialog-focus-pending"));
|
||||
let Some(mut d) = app.ui.dialog.clone() else {
|
||||
revolve_gizmo::clear(ctx);
|
||||
app.ui.dialog_file = None;
|
||||
@@ -494,7 +494,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
revolve_gizmo::show(app, ctx, &mut d);
|
||||
}
|
||||
let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) };
|
||||
let id = egui::Id::new(("dialog", d.kind.as_str()));
|
||||
let id = egui::Id::unique(("dialog", d.kind.as_str()));
|
||||
let id = if revolves { id.with("revolve") } else { id };
|
||||
modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| {
|
||||
// Never wider than the window (a large UI scale in a small window): the text wraps.
|
||||
@@ -590,6 +590,8 @@ pub(crate) fn mixed_dropdown(
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_accessibility;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_import_pdf;
|
||||
|
||||
@@ -39,7 +39,7 @@ pub(crate) fn show_at<R>(ctx: &egui::Context, title: &str, id: Id, initial: Vec2
|
||||
/// The layer of the backdrop behind a modal dialog window, which takes the clicks the rest of
|
||||
/// the app would get.
|
||||
pub(crate) fn backdrop() -> egui::LayerId {
|
||||
egui::LayerId::new(egui::Order::Middle, Id::new("modal-dim"))
|
||||
egui::LayerId::new(egui::Order::Middle, Id::unique("modal-dim"))
|
||||
}
|
||||
|
||||
/// The dialog's heading, which also moves the window ([`drag_band`]).
|
||||
|
||||
@@ -171,7 +171,7 @@ fn set_layout(d: &mut Dialog, key: &str, v: Value) {
|
||||
|
||||
/// The New Document dialog window `id` in the shared modal frame.
|
||||
fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) {
|
||||
super::modal::show(ctx, id, egui::Id::new(("dialog", id)), -20.0, margin, add);
|
||||
super::modal::show(ctx, id, egui::Id::unique(("dialog", id)), -20.0, margin, add);
|
||||
}
|
||||
|
||||
/// What the buttons of a window asked for.
|
||||
|
||||
@@ -71,29 +71,36 @@ fn split_grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
let gutter_label = form::humanized("gutter");
|
||||
egui::Grid::new("dlg").num_columns(4).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
for (number, label, ..) in axes {
|
||||
form::row_label(ui, label);
|
||||
if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", number), d.f64(number, 2.0), 1.0..=500.0, "", 0, WIDTH) {
|
||||
d.fields.insert(number.into(), json!(v));
|
||||
}
|
||||
let row = form::row_label(ui, label);
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", number), d.f64(number, 2.0), 1.0..=500.0, "", 0, WIDTH) {
|
||||
d.fields.insert(number.into(), json!(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
for (number, _, label, gutter, total) in axes {
|
||||
form::row_label(ui, label);
|
||||
let row = form::row_label(ui, label);
|
||||
let n = d.f64(number, 2.0).max(1.0);
|
||||
let gaps = d.f64(gutter, 0.0) * (n - 1.0);
|
||||
if let Some(v) = crate::widgets::num_field(ui, ("dlg-size", number), Some((d.f64(total, 0.0) - gaps) / n), unit, WIDTH) {
|
||||
d.fields.insert(total.into(), json!(n * v + gaps));
|
||||
}
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
if let Some(v) = crate::widgets::num_field(ui, ("dlg-size", number), Some((d.f64(total, 0.0) - gaps) / n), unit, WIDTH) {
|
||||
d.fields.insert(total.into(), json!(n * v + gaps));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
for (.., gutter, _) in axes {
|
||||
form::row_label(ui, &gutter_label);
|
||||
form::length(ui, d, gutter, unit, WIDTH);
|
||||
for (_, axis, _, gutter, _) in axes {
|
||||
// The visible rows stay the existing translated "Gutter:" and "Total:" labels; the
|
||||
// accessible names say which axis they belong to (two identical visible labels would
|
||||
// otherwise name two different inputs the same).
|
||||
let _ = form::row_label(ui, &gutter_label);
|
||||
ui.name_inputs(format!("{axis} {gutter_label}"), |ui| form::length(ui, d, gutter, unit, WIDTH));
|
||||
}
|
||||
ui.end_row();
|
||||
for (.., total) in axes {
|
||||
form::row_label(ui, tl!("Total:"));
|
||||
form::length(ui, d, total, unit, WIDTH);
|
||||
for (_, axis, _, _, total) in axes {
|
||||
let _ = form::row_label(ui, tl!("Total:"));
|
||||
ui.name_inputs(format!("{axis} {}", tl!("Total:")), |ui| form::length(ui, d, total, unit, WIDTH));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
@@ -178,7 +178,8 @@ fn third_vp(ui: &mut egui::Ui, d: &mut Dialog, i: usize, unit: Unit, enabled: bo
|
||||
v.resize(2, json!(0));
|
||||
let pt = v.get(i).and_then(Value::as_f64).map(|x| unit.to_pt(x));
|
||||
ui.label(if i == 0 { "X:" } else { "Y:" });
|
||||
if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field(ui, ("persp-vp3", i), pt, unit, 84.0)).inner {
|
||||
let name = if i == 0 { crate::i18n::t("X:") } else { crate::i18n::t("Y:") };
|
||||
if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field_named(ui, ("persp-vp3", i), Some(name), pt, unit, 84.0)).inner {
|
||||
v[i] = json!(unit.from_pt(x));
|
||||
d.fields.insert("thirdVp".into(), Value::Array(v));
|
||||
}
|
||||
|
||||
@@ -364,8 +364,8 @@ fn printer_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
/// document change.
|
||||
fn preview_of(app: &mut VectorcraftApp, ctx: &egui::Context, settings: &Value) -> Arc<Result<Value, String>> {
|
||||
let doc = app.session.active().map_or((0, 0), |s| (s.uid, s.revision));
|
||||
let stamp = egui::Id::new((settings.to_string(), doc));
|
||||
let key = egui::Id::new("print-preview");
|
||||
let stamp = egui::Id::unique((settings.to_string(), doc));
|
||||
let key = egui::Id::unique("print-preview");
|
||||
if let Some((s, v)) = ctx.data(|m| m.get_temp::<(egui::Id, Arc<Result<Value, String>>)>(key))
|
||||
&& s == stamp
|
||||
{
|
||||
@@ -419,7 +419,7 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv:
|
||||
painter.rect_filled(page, 0.0, egui::Color32::WHITE);
|
||||
// Dragging moves the art (and its trim box) on the paper; the placement changes on release.
|
||||
let tiling = choice::<PrintScaling>(d, "scaling").is_some_and(PrintScaling::tiles);
|
||||
let drag_id = egui::Id::new("print-preview-drag");
|
||||
let drag_id = egui::Id::unique("print-preview-drag");
|
||||
let mut offset = ui.ctx().data(|m| m.get_temp::<egui::Vec2>(drag_id)).unwrap_or_default();
|
||||
if !tiling && resp.dragged() {
|
||||
offset += resp.drag_delta();
|
||||
@@ -538,8 +538,8 @@ fn round2(v: f64) -> f64 {
|
||||
fn art_texture(app: &mut VectorcraftApp, ctx: &egui::Context, layers: PrintLayers, area: DRect, px: f64) -> Option<egui::TextureHandle> {
|
||||
let st = app.session.active()?;
|
||||
let bits = [area.x0, area.y0, area.x1, area.y1].map(f64::to_bits);
|
||||
let stamp = egui::Id::new((st.uid, st.revision, layers, bits, px.round() as u32));
|
||||
let key = egui::Id::new("print-preview-art");
|
||||
let stamp = egui::Id::unique((st.uid, st.revision, layers, bits, px.round() as u32));
|
||||
let key = egui::Id::unique("print-preview-art");
|
||||
if let Some((s, tex)) = ctx.data(|m| m.get_temp::<(egui::Id, egui::TextureHandle)>(key))
|
||||
&& s == stamp
|
||||
{
|
||||
@@ -695,7 +695,9 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>) {
|
||||
ui.label(egui::RichText::new(format!("{name}{spot}")).color(t.text));
|
||||
for (k, suffix) in [("frequency", " lpi"), ("angle", "°")] {
|
||||
let v = ink[k].as_f64().unwrap_or(0.0);
|
||||
if let Some(x) = widgets::plain_field(ui, ("print-ink", name, k), v, suffix, 2, 72.0) {
|
||||
let heading = if k == "frequency" { tl!("Frequency") } else { tl!("Angle") };
|
||||
let field_name = format!("{heading} {name}");
|
||||
if let Some(x) = widgets::plain_field_named(ui, ("print-ink", name, k), Some(&field_name), v, suffix, 2, 72.0) {
|
||||
set_ink(d, name, k, json!(x));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, "recolor-preset", PRESETS[preset_of(d)], &PRESETS, 130.0) {
|
||||
// The label is drawn after the menu (right-to-left), so it cannot name it by adjacency.
|
||||
if let Some(i) = widgets::dropdown_named(ui, "recolor-preset", Some(tl!("Preset:")), PRESETS[preset_of(d)], &PRESETS, 130.0) {
|
||||
set_preset(app, d, i);
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Preset:")).color(t.text_dim));
|
||||
@@ -242,7 +243,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") {
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Color Group:")).color(t.text_dim));
|
||||
widgets::dim_label(ui, tl!("Color Group:"));
|
||||
form::text(ui, d, "groupName", 200.0);
|
||||
});
|
||||
}
|
||||
@@ -322,7 +323,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
let labels: Vec<String> = std::iter::once(tl!("Auto").to_string()).chain((1..=total).map(|n| n.to_string())).collect();
|
||||
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
let cur = d.fields.get("colors").and_then(Value::as_u64).map_or(tl!("Auto").to_string(), |n| n.to_string());
|
||||
if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) {
|
||||
if let Some(i) = widgets::dropdown_named(ui, "recolor-count", Some(tl!("Colors:")), &cur, &refs, 70.0) {
|
||||
d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) });
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
@@ -391,7 +392,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let hex = crate::panels::color::hex_digits(&to);
|
||||
if let Some(h) = widgets::hex_field(ui, ("recolor-hex", i), &hex)
|
||||
let hex_name = format!("{} {}", tl!("Color"), i + 1);
|
||||
if let Some(h) = widgets::hex_field_named(ui, ("recolor-hex", i), Some(&hex_name), &hex)
|
||||
&& let Some(c) = crate::panels::color::parse_hex(&h)
|
||||
{
|
||||
row["to"] = key(&keep_model(to, c));
|
||||
@@ -455,7 +457,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
ui.label(egui::RichText::new(tl!("Method:")).color(t.text_dim));
|
||||
let m = Method::parse(&d.str("method")).unwrap_or_default();
|
||||
let labels: Vec<&str> = Method::ALL.iter().map(|m| m.label()).collect();
|
||||
if let Some(i) = widgets::dropdown(ui, "recolor-method", m.label(), &labels, 180.0) {
|
||||
let chosen = ui.name_inputs(tl!("Method:"), |ui| widgets::dropdown(ui, "recolor-method", m.label(), &labels, 180.0));
|
||||
if let Some(i) = chosen {
|
||||
d.fields.insert("method".into(), json!(Method::ALL[i].id()));
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -471,7 +474,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
let at = keys.iter().position(|k| *k == cur).unwrap_or(0);
|
||||
let current = shown.get(at).copied().unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) {
|
||||
let library = ui.name_inputs(tl!("Limit to Library:"), |ui| widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0));
|
||||
if let Some(k) = library.and_then(|i| keys.get(i)) {
|
||||
d.fields.insert("limitTo".into(), json!(k));
|
||||
}
|
||||
// Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it.
|
||||
@@ -578,8 +582,9 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
|
||||
ui.label(egui::RichText::new(tl!("Harmony Rules:")).color(t.text_dim));
|
||||
let rule = Harmony::parse(&d.str("rule")).unwrap_or(Harmony::Complementary);
|
||||
let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect();
|
||||
// Choosing a rule gives the new colours its colours, from the base colour.
|
||||
if let Some(i) = widgets::dropdown(ui, "recolor-rule", rule.label(), &labels, 170.0) {
|
||||
// Choosing a rule gives the new colours its colours, from the base colour. The heading is
|
||||
// not a field label widget, so the menu carries its own accessible name.
|
||||
if let Some(i) = widgets::dropdown_named(ui, "recolor-rule", Some(tl!("Harmony Rules:")), rule.label(), &labels, 170.0) {
|
||||
d.fields.insert("rule".into(), json!(Harmony::ALL[i].id()));
|
||||
if let Some((b, c)) = base.and_then(|b| Some((b, to_color(&rows[b])?))) {
|
||||
apply_rule(rows, Harmony::ALL[i], b, c, &active);
|
||||
|
||||
@@ -40,8 +40,8 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6))
|
||||
});
|
||||
let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover());
|
||||
let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all")));
|
||||
let response = ui.interact(r, Id::unique("revolve-reset-all"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset all")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
@@ -572,8 +572,8 @@ fn dot(ui: &egui::Ui, p: Pos2, changed: bool) {
|
||||
|
||||
fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
|
||||
let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default")));
|
||||
let response = ui.interact(r, Id::unique(("revolve-reset", key)), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset to default")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) fn hint(d: &Dialog) -> &'static str {
|
||||
}
|
||||
|
||||
fn drag_id() -> Id {
|
||||
Id::new("revolve-gizmo-drag")
|
||||
Id::unique("revolve-gizmo-drag")
|
||||
}
|
||||
|
||||
pub(crate) fn dragging(ctx: &egui::Context) -> bool {
|
||||
@@ -201,7 +201,7 @@ pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
|
||||
rect = rect.union(label.expand(2.0));
|
||||
}
|
||||
}
|
||||
egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| {
|
||||
egui::Area::new(Id::unique("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| {
|
||||
ui.set_clip_rect(geometry.xf.rect);
|
||||
let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag());
|
||||
let options = super::revolve::light_options(d);
|
||||
|
||||
@@ -373,7 +373,7 @@ struct Cache {
|
||||
}
|
||||
|
||||
fn cache_id() -> egui::Id {
|
||||
egui::Id::new("save-for-web-previews")
|
||||
egui::Id::unique("save-for-web-previews")
|
||||
}
|
||||
|
||||
/// The texture of straight RGBA pixels.
|
||||
@@ -508,7 +508,7 @@ fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, set
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let (area, resp) = ui.allocate_exact_size(egui::vec2(PREVIEW_W, PREVIEW_H), egui::Sense::click_and_drag());
|
||||
let pan_id = egui::Id::new("save-for-web-pan");
|
||||
let pan_id = egui::Id::unique("save-for-web-pan");
|
||||
let mut pan: egui::Vec2 = ui.ctx().data(|m| m.get_temp(pan_id)).unwrap_or_default();
|
||||
if resp.dragged() {
|
||||
pan += resp.drag_delta();
|
||||
@@ -554,14 +554,16 @@ fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, set
|
||||
let mut browser = false;
|
||||
ui.horizontal(|ui| {
|
||||
let z = ZOOMS.iter().position(|z| *z == d.str("__zoom")).unwrap_or(2);
|
||||
if let Some(i) = widgets::dropdown(ui, "sfw-zoom", ZOOM_LABELS[z], &ZOOM_LABELS, 110.0) {
|
||||
let zoom = ui.name_inputs(tl!("Zoom"), |ui| widgets::dropdown(ui, "sfw-zoom", ZOOM_LABELS[z], &ZOOM_LABELS, 110.0));
|
||||
if let Some(i) = zoom {
|
||||
d.fields.insert("__zoom".into(), json!(ZOOMS[i]));
|
||||
ui.ctx().data_mut(|m| m.insert_temp(pan_id, egui::Vec2::ZERO));
|
||||
}
|
||||
let kbps = d.f64("__kbps", 56.6);
|
||||
let labels: Vec<String> = SPEEDS.iter().map(|s| speed_label(*s)).collect();
|
||||
let names: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
if let Some(i) = widgets::dropdown(ui, "sfw-speed", &speed_label(kbps), &names, 110.0)
|
||||
let speed = ui.name_inputs(tl!("Download:"), |ui| widgets::dropdown(ui, "sfw-speed", &speed_label(kbps), &names, 110.0));
|
||||
if let Some(i) = speed
|
||||
&& let Some(s) = SPEEDS.get(i)
|
||||
{
|
||||
d.fields.insert("__kbps".into(), json!(s));
|
||||
|
||||
@@ -256,11 +256,13 @@ pub(super) fn row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui
|
||||
pub(super) fn row_with(ui: &mut egui::Ui, label: &str, width: f32, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(width, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
ui.label(egui::RichText::new(label).color(t.text));
|
||||
});
|
||||
add(ui);
|
||||
let label_id = ui
|
||||
.allocate_ui_with_layout(egui::vec2(width, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
ui.label(egui::RichText::new(label).color(t.text)).id
|
||||
})
|
||||
.inner;
|
||||
ui.label_inputs_by(label_id, add);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -477,7 +479,7 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
|
||||
flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true);
|
||||
if editor {
|
||||
heading(ui, tl!("Description"));
|
||||
if let Some(text) = widgets::text_field(ui, "pdf-preset-description", Some(&d.str("description")), 460.0, 3) {
|
||||
if let Some(text) = widgets::text_field_named(ui, "pdf-preset-description", Some(tl!("Description")), Some(&d.str("description")), 460.0, 3) {
|
||||
d.fields.insert("description".into(), json!(text));
|
||||
}
|
||||
return;
|
||||
@@ -766,8 +768,8 @@ pub(super) fn option_label(option: &str) -> String {
|
||||
/// cached until a field or the document changes.
|
||||
fn summary_of(app: &mut VectorcraftApp, ctx: &egui::Context, p: &Value) -> Result<Value, String> {
|
||||
let revision = app.session.active().map_or(0, |s| s.revision);
|
||||
let stamp = egui::Id::new((p.to_string(), revision));
|
||||
let key = egui::Id::new("save-pdf-summary");
|
||||
let stamp = egui::Id::unique((p.to_string(), revision));
|
||||
let key = egui::Id::unique("save-pdf-summary");
|
||||
if let Some((s, v)) = ctx.data(|m| m.get_temp::<(egui::Id, Result<Value, String>)>(key))
|
||||
&& s == stamp
|
||||
{
|
||||
|
||||
@@ -654,7 +654,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let statuses = review.statuses();
|
||||
// The Keys and Shortcuts lists get the window's height less the rest of the dialog, as the frame
|
||||
// before drew it.
|
||||
let id = egui::Id::new("dialog-shortcut-import");
|
||||
let id = egui::Id::unique("dialog-shortcut-import");
|
||||
let rest = ctx.data(|m| m.get_temp::<f32>(id.with("rest"))).unwrap_or(0.0);
|
||||
let (keys, mods) = review.asked();
|
||||
let keys_open = d.bool("__keysOpen") && (!keys.is_empty() || !mods.is_empty());
|
||||
@@ -928,7 +928,7 @@ fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses
|
||||
fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option<String> {
|
||||
let current = target_label(r.target.as_deref());
|
||||
widgets::combo(ui, ("shortcut-import-target", i), ¤t, TARGET_W, true, |ui| {
|
||||
let field = ui.id().with("query");
|
||||
let field = ui.scope_id().with("query");
|
||||
if ui.memory(|m| m.focused().is_none()) {
|
||||
ui.memory_mut(|m| m.request_focus(field.with("edit")));
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ enum Action {
|
||||
fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let id = egui::Id::new(("dialog", KIND));
|
||||
let id = egui::Id::unique(("dialog", KIND));
|
||||
let b = bounds(app, &mut d);
|
||||
let pos = place(app, ctx, id, b);
|
||||
// A click anywhere else keeps the result, and the rest of the app doesn't take it.
|
||||
|
||||
@@ -266,8 +266,8 @@ fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) {
|
||||
o.insert("artboard".into(), json!(first.map_or(0, |n| n.saturating_sub(1))));
|
||||
}
|
||||
// One cached text, replaced when the document or the options change.
|
||||
let key = egui::Id::new("svg-code");
|
||||
let sig = egui::Id::new((st.uid, st.revision, opts.to_string())).value();
|
||||
let key = egui::Id::unique("svg-code");
|
||||
let sig = egui::Id::unique((st.uid, st.revision, opts.to_string())).value();
|
||||
let code: Arc<String> = match ui.ctx().data(|m| m.get_temp::<(u64, Arc<String>)>(key)).filter(|(s, _)| *s == sig) {
|
||||
Some((_, c)) => c,
|
||||
None => {
|
||||
|
||||
@@ -252,7 +252,7 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
/// A hex field showing `hex`; returns the colour typed into it once committed.
|
||||
fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option<Color> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = ui.id().with("swatch-hex");
|
||||
let id = ui.scope_id().with("swatch-hex");
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
// As in `widgets::hex_field`: until the user types into it, the field shows `hex` as it is now
|
||||
// (#1692).
|
||||
|
||||
@@ -358,7 +358,7 @@ fn dialogs_are_as_tall_as_their_content() {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind)))).unwrap().height()
|
||||
ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", kind)))).unwrap().height()
|
||||
};
|
||||
let tall = height(&mut app, "command", json!({"__command": "object.move", "a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "f": 6, "g": 7, "h": 8}));
|
||||
let prompt = height(&mut app, crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"}));
|
||||
@@ -403,7 +403,7 @@ fn dialogs_do_not_stretch_to_the_screen() {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
let h = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind.as_str()))))?.height();
|
||||
let h = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", kind.as_str()))))?.height();
|
||||
Some((kind, h))
|
||||
};
|
||||
let mut checked = 0;
|
||||
@@ -426,7 +426,7 @@ fn dialog_rect(app: &mut VectorcraftApp, w: f32) -> egui::Rect {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind.as_str())))).unwrap()
|
||||
ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", kind.as_str())))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Strict accessibility checks on the real dialogs: kittest fails any input without a name, and
|
||||
//! each test also asks for the names a screen reader must hear on the fields this migration
|
||||
//! touched (form text fields, parameter fields, bleed, split-grid context).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::show;
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, VectorcraftApp>;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// A kittest harness over the real dialog layer, with kittest's strict accessibility check on:
|
||||
/// every visible input must have an accessible name.
|
||||
fn strict(app: VectorcraftApp) -> Harness {
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1100.0, 900.0)).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h.check_accessibility();
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_names_the_form_text_fields() {
|
||||
let mut app = app();
|
||||
app.run("file.exportForScreens", json!({})).unwrap();
|
||||
let h = strict(app);
|
||||
for name in ["File Name:", "Prefix:", "Start at:"] {
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, name);
|
||||
}
|
||||
assert!(h.query_by_role_and_label(Role::TextInput, "name").is_none(), "no internal widget id");
|
||||
assert!(h.query_by_role_and_label(Role::TextInput, "prefix").is_none(), "no internal widget id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_transparency_names_its_preset_and_option_fields() {
|
||||
let mut app = app();
|
||||
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
|
||||
{
|
||||
let d = app.ui.dialog.as_mut().expect("Flatten Transparency is open");
|
||||
d.fields.insert("__saving".into(), json!(true));
|
||||
d.fields.insert("__presetName".into(), json!("Mine"));
|
||||
}
|
||||
let h = strict(app);
|
||||
for name in ["Name:", "Raster/Vector Balance:", "Line Art and Text Resolution:", "Gradient and Mesh Resolution:"] {
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, name);
|
||||
}
|
||||
let _ = h.get_by_role_and_label(Role::Slider, "Raster/Vector Balance:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_setup_names_each_bleed_side() {
|
||||
let mut app = app();
|
||||
app.run("file.documentSetup", json!({})).unwrap();
|
||||
let h = strict(app);
|
||||
for side in ["Top", "Bottom", "Left", "Right"] {
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, side);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_into_grid_names_row_and_column_fields_with_context() {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 120, "height": 80})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, "object.path.splitIntoGrid", json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("splitIntoGrid"));
|
||||
let h = strict(app);
|
||||
for name in ["Rows: Gutter:", "Columns: Gutter:", "Rows: Total:", "Columns: Total:"] {
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, name);
|
||||
}
|
||||
// The visible rows keep the existing translated labels; only their accessible names carry
|
||||
// the axis. Two labels of each remain, so the two inputs would otherwise share a name.
|
||||
assert_eq!(h.get_all_by_label("Gutter:").count(), 2, "the visible gutter labels are unchanged");
|
||||
assert_eq!(h.get_all_by_label("Total:").count(), 2, "the visible total labels are unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_options_names_the_video_pixel_aspect_ratio_field() {
|
||||
let mut app = app();
|
||||
app.select_tool("artboard");
|
||||
app.run("ui.newArtboard", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("artboardOptions"));
|
||||
let h = strict(app);
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, "Video Ruler Pixel Aspect Ratio:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_parameter_dialog_names_numbers_text_and_choices() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"ui.paramDialog",
|
||||
json!({
|
||||
"command": "type.pathOptions",
|
||||
"label": "Type on a Path Options",
|
||||
"params": {
|
||||
"effect": "rainbow",
|
||||
"alignToPath": "baseline",
|
||||
"spacing": 10.0,
|
||||
"flip": false,
|
||||
"note": "Draft"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let h = strict(app);
|
||||
for name in ["Spacing:", "Note:"] {
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, name);
|
||||
}
|
||||
let _ = h.get_by_role_and_label(Role::ComboBox, "Effect:");
|
||||
let _ = h.get_by_role_and_label(Role::ComboBox, "Align to Path:");
|
||||
let _ = h.get_by_role_and_label(Role::CheckBox, "Flip:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_options_names_its_text_area() {
|
||||
let mut app = app();
|
||||
app.run("object.slice.create", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("object.slice.options", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().expect("Slice Options is open").fields.insert("kind".into(), json!("noImage"));
|
||||
let h = strict(app);
|
||||
let _ = h.get_by_role_and_label(Role::MultilineTextInput, "Text Displayed in Cell:");
|
||||
}
|
||||
@@ -87,10 +87,10 @@ type Open = fn(&mut VectorcraftApp);
|
||||
fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||||
let opens: [(&str, Open, egui::Id); 4] = [
|
||||
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::new(("dialog", "offsetPath"))),
|
||||
("New Document", open_new_document, egui::Id::new(("dialog", "newDocument"))),
|
||||
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::new("dialog-preferences")),
|
||||
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::new("dialog-shortcuts")),
|
||||
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::unique(("dialog", "offsetPath"))),
|
||||
("New Document", open_new_document, egui::Id::unique(("dialog", "newDocument"))),
|
||||
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::unique("dialog-preferences")),
|
||||
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::unique("dialog-shortcuts")),
|
||||
];
|
||||
for (name, open, id) in opens {
|
||||
let (ctx, mut app) = (ctx(), app_with_selection());
|
||||
@@ -134,7 +134,7 @@ fn check_plugin_dialog(kind: &str, count: usize, check: impl FnOnce(&egui::Conte
|
||||
};
|
||||
app.run(command, params).unwrap();
|
||||
settle(&ctx, &mut app);
|
||||
check(&ctx, &mut app, egui::Id::new(("dialog", dialog)), &format!("Test {id}"));
|
||||
check(&ctx, &mut app, egui::Id::unique(("dialog", dialog)), &format!("Test {id}"));
|
||||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ fn a_plug_in_dialog_of_many_settings_fits_the_window() {
|
||||
delta: vec2(0.0, -200.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
frame(ctx, app, vec![Event::PointerMoved(over), wheel]);
|
||||
for _ in 0..30 {
|
||||
@@ -318,7 +319,7 @@ fn simplify_preview(app: &VectorcraftApp) -> serde_json::Value {
|
||||
/// The Simplify slider's strip, and the centers of its Auto-Simplify button, its rail and its More
|
||||
/// Options button.
|
||||
fn slider_strip(ctx: &egui::Context) -> (Rect, Pos2, Pos2, Pos2) {
|
||||
let r = rect_of(ctx, egui::Id::new(("dialog", simplify_slider::KIND)));
|
||||
let r = rect_of(ctx, egui::Id::unique(("dialog", simplify_slider::KIND)));
|
||||
let y = r.center().y;
|
||||
(r, Pos2::new(r.left() + 8.0 + 14.0, y), Pos2::new(r.left() + 8.0 + 28.0 + 6.0 + 90.0, y), Pos2::new(r.right() - 8.0 - 14.0, y))
|
||||
}
|
||||
@@ -430,13 +431,13 @@ fn simplify_slider_stays_where_it_opened_while_the_preview_changes_the_bounds()
|
||||
let before = app.selection_bounds().unwrap();
|
||||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||||
settle(&ctx, &mut app);
|
||||
let opened = rect_of(&ctx, egui::Id::new(("dialog", simplify_slider::KIND)));
|
||||
let opened = rect_of(&ctx, egui::Id::unique(("dialog", simplify_slider::KIND)));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("level".into(), json!(0));
|
||||
settle(&ctx, &mut app);
|
||||
assert_eq!(simplify_preview(&app), json!({"level": 0.0}));
|
||||
let after = app.selection_bounds().unwrap();
|
||||
assert!(after.height() < before.height() - 0.5, "the preview changes the bounds: {before:?} → {after:?}");
|
||||
assert_eq!(rect_of(&ctx, egui::Id::new(("dialog", simplify_slider::KIND))), opened);
|
||||
assert_eq!(rect_of(&ctx, egui::Id::unique(("dialog", simplify_slider::KIND))), opened);
|
||||
}
|
||||
|
||||
/// Auto-Simplify moves the slider back to the level that keeps the path within 1/400 of its size,
|
||||
|
||||
@@ -57,7 +57,7 @@ impl Harness {
|
||||
}
|
||||
|
||||
fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect {
|
||||
self.ctx.read_response(Id::new(id)).unwrap().rect
|
||||
self.ctx.read_response(Id::unique(id)).unwrap().rect
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) {
|
||||
@@ -90,7 +90,7 @@ fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step(
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window");
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
// Cap adds one 28-point row to the shared dialog's previous 540-point bound.
|
||||
assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact controls with Cap: {panel:?}");
|
||||
let label = h.rect("Rotation X");
|
||||
@@ -156,7 +156,7 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
|
||||
for label in ["Shading", "Color", "Intensity", "Height", "Softness", "Ambient Light", "Light behind the object"] {
|
||||
assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label)));
|
||||
}
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact lighting controls with light management: {panel:?}");
|
||||
h.drag(h.rect("Height").center(), vec2(24.0, 0.0));
|
||||
assert_ne!(h.value("lightHeight"), 30.0);
|
||||
@@ -220,7 +220,7 @@ fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_co
|
||||
let mut h = Harness::new(vec2(1280.0, 1000.0));
|
||||
h.settle();
|
||||
h.click(h.rect("Advanced").center());
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
h.frame(vec![
|
||||
Event::PointerMoved(panel.center()),
|
||||
Event::MouseWheel {
|
||||
@@ -228,6 +228,7 @@ fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_co
|
||||
delta: vec2(0.0, -220.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
]);
|
||||
h.settle();
|
||||
@@ -300,10 +301,10 @@ fn extrude_bevel_options_enable_with_a_bevel_shape() {
|
||||
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0));
|
||||
h.settle();
|
||||
let width = h.response(("revolve-reset", "bevelWidth"));
|
||||
assert!(!h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled(), "no bevel: its sizes are off");
|
||||
assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "bevelWidth"))).unwrap().enabled(), "no bevel: its sizes are off");
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("bevel".into(), json!("rounded"));
|
||||
h.settle();
|
||||
assert!(h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled());
|
||||
assert!(h.ctx.read_response(Id::unique(("revolve-reset", "bevelWidth"))).unwrap().enabled());
|
||||
assert_eq!(h.response(("revolve-reset", "bevelWidth")), width);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ fn prompt(app: &mut VectorcraftApp, ctx: &egui::Context, physical: egui::Vec2, z
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", crate::unsaved::KIND)))).unwrap();
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", crate::unsaved::KIND)))).unwrap();
|
||||
(rect, screen)
|
||||
}
|
||||
|
||||
|
||||
@@ -506,9 +506,9 @@ fn tab_to_warp_control(app: &mut VectorcraftApp, ctx: &egui::Context, label: &st
|
||||
if !events.iter().any(|event| {
|
||||
let egui::output::OutputEvent::FocusGained(info) = event else { return false };
|
||||
info.enabled
|
||||
&& match info.typ {
|
||||
egui::WidgetType::ComboBox => info.current_text_value.as_deref() == Some(label),
|
||||
egui::WidgetType::Button => info.label.as_deref() == Some(label),
|
||||
&& match info.role {
|
||||
egui::Role::ComboBox => info.current_text_value.as_deref() == Some(label),
|
||||
egui::Role::Button => info.label.as_deref() == Some(label),
|
||||
_ => false,
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -229,7 +229,7 @@ mod tests {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| super::super::show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", KIND)))).unwrap().height()
|
||||
ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap().height()
|
||||
};
|
||||
let (short, tall) = (height(1000.0), height(2000.0));
|
||||
assert!((short - tall).abs() < 1.0 && short < 400.0, "{short} vs {tall}");
|
||||
|
||||
+33
-29
@@ -31,30 +31,32 @@ pub(crate) fn clamp_shared_dock_width(width: f32) -> f32 {
|
||||
width.clamp(SHARED_DOCK_MIN, SHARED_DOCK_MAX)
|
||||
}
|
||||
|
||||
fn seed_dock_panel_width(ctx: &egui::Context, panel: &str, width: f32) {
|
||||
let screen = ctx.content_rect();
|
||||
/// Seed the panel's public persisted state before it is laid out: `Panel` keys it by the parent
|
||||
/// `Ui`'s scope id and its own salt in 0.37, not by a global `Id` (the old `Id::unique(panel)` key
|
||||
/// silently stopped restoring the width). Keep this in one place so the key cannot drift.
|
||||
fn seed_dock_panel_width(ui: &egui::Ui, panel: &str, width: f32) {
|
||||
let screen = ui.ctx().content_rect();
|
||||
let w = width.max(1.0);
|
||||
let outer_rect = egui::Rect::from_min_size(egui::pos2(screen.right() - w, screen.top()), egui::vec2(w, screen.height()));
|
||||
let state = egui::containers::panel::PanelState { outer_rect };
|
||||
ctx.data_mut(|data| data.insert_persisted(egui::Id::new(panel), state));
|
||||
ui.ctx().data_mut(|data| data.insert_persisted(ui.scope_id().with(panel), state));
|
||||
}
|
||||
|
||||
pub(crate) fn apply_pending_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str, clamp: fn(f32) -> f32) {
|
||||
pub(crate) fn apply_pending_dock_width(app: &mut VectorcraftApp, ui: &egui::Ui, panel: &str, clamp: fn(f32) -> f32) {
|
||||
if !app.ui.dock_width_pending {
|
||||
return;
|
||||
}
|
||||
if let Some(width) = app.ui.dock_width.filter(|w| w.is_finite() && *w > 0.0) {
|
||||
seed_dock_panel_width(ctx, panel, clamp(width));
|
||||
seed_dock_panel_width(ui, panel, clamp(width));
|
||||
}
|
||||
app.ui.dock_width_pending = false;
|
||||
}
|
||||
|
||||
pub(crate) fn note_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str) {
|
||||
if let Some(state) = egui::containers::panel::PanelState::load(ctx, egui::Id::new(panel)) {
|
||||
let width = state.size().x;
|
||||
if width.is_finite() && width > 0.0 {
|
||||
app.ui.dock_width = Some(width);
|
||||
}
|
||||
/// Remember the width the panel was actually laid out at (its `response.rect`), so a workspace
|
||||
/// switch or the next session restores it without reaching into egui's private `PanelState`.
|
||||
pub(crate) fn note_dock_width(app: &mut VectorcraftApp, width: f32) {
|
||||
if width.is_finite() && width > 0.0 {
|
||||
app.ui.dock_width = Some(width);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +72,13 @@ const FLYOUT_TOP: f32 = 110.0;
|
||||
|
||||
/// Where the icon column's left edge was drawn last (egui temp memory, one frame old).
|
||||
fn column_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-icon-column-left")
|
||||
egui::Id::unique("dock-icon-column-left")
|
||||
}
|
||||
|
||||
/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's
|
||||
/// (egui temp memory, one frame old). Popped-out panels open left of it.
|
||||
pub(crate) fn dock_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-left")
|
||||
egui::Id::unique("dock-left")
|
||||
}
|
||||
|
||||
/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while
|
||||
@@ -118,8 +120,8 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s
|
||||
let open = app.ui.open_panel.as_deref() == Some(id);
|
||||
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
|
||||
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
|
||||
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label)));
|
||||
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.scope_id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), open, tl!(label)));
|
||||
if resp.clicked() {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
}
|
||||
@@ -131,7 +133,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if crate::panel_docking::show(app, ui) {
|
||||
return;
|
||||
}
|
||||
apply_pending_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL, clamp_legacy_dock_width);
|
||||
apply_pending_dock_width(app, ui, LEGACY_DOCK_PANEL, clamp_legacy_dock_width);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = floating::shown_tab(&app.ui);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), Vec::<floating::RootGroup>::new()));
|
||||
@@ -178,7 +180,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.response
|
||||
.rect
|
||||
});
|
||||
note_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL);
|
||||
if let Some(rect) = group {
|
||||
note_dock_width(app, rect.width());
|
||||
}
|
||||
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
|
||||
// collapsed group's panels head it, under the « that expands them again. Floating panels leave
|
||||
// it.
|
||||
@@ -277,7 +281,7 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
|
||||
}
|
||||
let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
|
||||
if rest.width() > 0.0 {
|
||||
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::click_and_drag());
|
||||
let resp = ui.interact(rest, ui.scope_id().with("dock-group-bar"), Sense::click_and_drag());
|
||||
if resp.double_clicked() {
|
||||
if let Err(error) = app.run("window.panel.collapse", json!({ "panel": active.info().0 })) {
|
||||
app.ui.status = error;
|
||||
@@ -369,7 +373,7 @@ pub(crate) fn panel_tabs(
|
||||
} else {
|
||||
galley.clone()
|
||||
};
|
||||
let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected)
|
||||
let resp = craft_ui::tabs::Tab::new(ui.scope_id().with((id, key)), label, i == selected)
|
||||
.sense(Sense::click_and_drag())
|
||||
.focus_stroke(Stroke::new(1.0, t.accent))
|
||||
.show_at(ui, rect, |ui, _| {
|
||||
@@ -384,7 +388,7 @@ pub(crate) fn panel_tabs(
|
||||
}
|
||||
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
|
||||
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height()));
|
||||
let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click());
|
||||
let resp = ui.interact(rect, ui.scope_id().with((id, "overflow")), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 0.0, t.hover);
|
||||
}
|
||||
@@ -392,7 +396,7 @@ pub(crate) fn panel_tabs(
|
||||
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels")));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Panels")));
|
||||
let resp = resp.on_hover_text(tl!("Panels"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
@@ -451,7 +455,7 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0);
|
||||
let mut open = true;
|
||||
let mut torn = None;
|
||||
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
|
||||
let area = egui::Area::new(egui::Id::unique("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
|
||||
area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
@@ -462,14 +466,14 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, &label, theme::semibold(12.0), t.text);
|
||||
let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
|
||||
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
|
||||
let cr = ui.interact(close, ui.scope_id().with("close-panel"), Sense::click());
|
||||
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
|
||||
if cr.clicked() {
|
||||
open = false;
|
||||
}
|
||||
let menu_r = Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
|
||||
panels::panel_menu(app, ui, id, menu_r);
|
||||
let drag = ui.interact(title, ui.id().with("title"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
|
||||
let drag = ui.interact(title, ui.scope_id().with("title"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
|
||||
if drag.dragged() && ui.input(|i| i.pointer.is_decidedly_dragging()) {
|
||||
torn = Some(title.left());
|
||||
}
|
||||
@@ -655,7 +659,7 @@ mod tests {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| floating_panel(&mut app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap();
|
||||
if rect.right() > column {
|
||||
too_wide.push(format!("{panel}: {:.0} past the column", rect.right() - column));
|
||||
}
|
||||
@@ -871,7 +875,7 @@ mod tests {
|
||||
h.click(layers.center());
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
|
||||
h.settle();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap();
|
||||
let column = h.column_left();
|
||||
assert!(rect.right() <= column, "the flyout {rect:?} stays left of the column at {column}");
|
||||
assert!(rect.left() >= 0.0 && rect.bottom() <= SCREEN.y, "the flyout {rect:?} is on screen");
|
||||
@@ -892,7 +896,7 @@ mod tests {
|
||||
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
|
||||
h.app.run("window.panel", json!({"panel": "properties"})).unwrap();
|
||||
h.settle();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap();
|
||||
assert!(rect.width() >= DOCK_WIDTH && rect.right() <= h.column_left(), "Properties flyout {rect:?}");
|
||||
// Taller than the 400 points egui sizes a new area at: its sections (Align, Quick
|
||||
// Actions) aren't cut off below Appearance.
|
||||
@@ -917,7 +921,7 @@ mod tests {
|
||||
assert_eq!(h.app.ui.open_panel, None, "on: a click elsewhere puts it away");
|
||||
h.click(layers.center());
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "its icon still pops it out");
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap();
|
||||
h.click(egui::pos2(rect.center().x, rect.top() + 13.0 + 26.0 + 10.0));
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "a click inside keeps it");
|
||||
// The icons still swap the flyout and put it away.
|
||||
@@ -929,7 +933,7 @@ mod tests {
|
||||
h.click(layers.center());
|
||||
h.app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt", "dy": "0 pt"})));
|
||||
h.settle();
|
||||
let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "move")))).expect("the Move dialog");
|
||||
let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "move")))).expect("the Move dialog");
|
||||
h.click(dialog.left_top() + vec2(20.0, 30.0));
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "a click in the dialog keeps it");
|
||||
h.click(egui::pos2(dialog.left() - 40.0, dialog.center().y));
|
||||
|
||||
@@ -31,15 +31,15 @@ struct Hover {
|
||||
|
||||
/// Key for the source document and original canvas grab point.
|
||||
fn origin_id() -> Id {
|
||||
Id::new("document-drag-origin")
|
||||
Id::unique("document-drag-origin")
|
||||
}
|
||||
/// Key for the tab identity and dwell start time of the current hover.
|
||||
fn hover_id() -> Id {
|
||||
Id::new("document-drag-hover")
|
||||
Id::unique("document-drag-hover")
|
||||
}
|
||||
/// Key for the pointer position latched after tab activation to prevent cascading switches.
|
||||
fn activated_at_id() -> Id {
|
||||
Id::new("document-drag-activated-at")
|
||||
Id::unique("document-drag-activated-at")
|
||||
}
|
||||
|
||||
/// Record the Selection drag source and grab point, clearing any previous tab-hover timer.
|
||||
|
||||
@@ -330,16 +330,16 @@ pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, R
|
||||
|
||||
/// Where the toolbar leaves its ••• button's rect and its own for the drawer, each frame.
|
||||
pub(crate) fn button_id() -> egui::Id {
|
||||
egui::Id::new("edit-toolbar-button")
|
||||
egui::Id::unique("edit-toolbar-button")
|
||||
}
|
||||
|
||||
pub(crate) fn toolbar_id() -> egui::Id {
|
||||
egui::Id::new("edit-toolbar-toolbar")
|
||||
egui::Id::unique("edit-toolbar-toolbar")
|
||||
}
|
||||
|
||||
/// The drawer's area.
|
||||
pub(crate) fn drawer_id() -> egui::Id {
|
||||
egui::Id::new("tools-drawer")
|
||||
egui::Id::unique("tools-drawer")
|
||||
}
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
@@ -436,7 +436,7 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (r, menu) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
icons::paint(ui, "menu", r.shrink(1.0), if menu.hovered() { t.text_strong } else { t.text_dim });
|
||||
menu.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Drawer menu")));
|
||||
menu.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Drawer menu")));
|
||||
let menu = menu.on_hover_text(tl!("Drawer menu"));
|
||||
egui::Popup::menu(&menu).id(menu_id()).show(|ui| {
|
||||
ui.set_min_width(160.0);
|
||||
@@ -530,7 +530,7 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
/// and Coloring are drawn as small copies of those controls, the modes with their buttons' icons.
|
||||
fn show_button(ui: &mut Ui, t: &Tokens, name: &str, label: &str, icon: &str, on: bool) -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tl!(label)));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, tl!(label)));
|
||||
let p = ui.painter();
|
||||
if on {
|
||||
p.rect_filled(r, 3.0, t.tool_active);
|
||||
@@ -571,7 +571,7 @@ fn show_button(ui: &mut Ui, t: &Tokens, name: &str, label: &str, icon: &str, on:
|
||||
|
||||
/// The drawer's flyout menu.
|
||||
fn menu_id() -> egui::Id {
|
||||
egui::Id::new("tools-drawer-menu")
|
||||
egui::Id::unique("tools-drawer-menu")
|
||||
}
|
||||
|
||||
/// A tool's row in the drawer: icon, name and shortcut, dimmed when the tool is in the toolbar
|
||||
@@ -628,7 +628,7 @@ fn ghost(ctx: &egui::Context, t: &Tokens) {
|
||||
let Some(d) = DragAndDrop::payload::<ToolDrag>(ctx) else { return };
|
||||
let (Some(at), Some(tool)) = (ctx.pointer_interact_pos(), tool_info(d.tool)) else { return };
|
||||
let r = Rect::from_min_size(at + vec2(8.0, 8.0), vec2(GHOST, GHOST));
|
||||
egui::Area::new(egui::Id::new("tool-drag-ghost")).order(egui::Order::Tooltip).fixed_pos(r.min).interactable(false).show(ctx, |ui| {
|
||||
egui::Area::new(egui::Id::unique("tool-drag-ghost")).order(egui::Order::Tooltip).fixed_pos(r.min).interactable(false).show(ctx, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(r.size(), Sense::hover());
|
||||
ui.painter().rect(r, 3.0, t.panel, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), Rect::from_center_size(r.center(), vec2(18.0, 18.0)), t.text_strong);
|
||||
|
||||
@@ -20,7 +20,7 @@ pub const DELAY: f64 = 0.4;
|
||||
|
||||
/// Where the job a frame first saw is remembered (its id, the time it was first seen).
|
||||
fn seen_id() -> egui::Id {
|
||||
egui::Id::new("export-progress-seen")
|
||||
egui::Id::unique("export-progress-seen")
|
||||
}
|
||||
|
||||
/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one,
|
||||
@@ -103,7 +103,7 @@ pub enum Pressed {
|
||||
/// ([`crate::plugin_runs`]).
|
||||
pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&mut egui::Ui)) -> Option<Pressed> {
|
||||
let mut pressed = None;
|
||||
modal::show(ctx, heading, egui::Id::new(id), -40.0, 20, |ui| {
|
||||
modal::show(ctx, heading, egui::Id::unique(id), -40.0, 20, |ui| {
|
||||
ui.set_width(360.0);
|
||||
modal::heading(ui, heading);
|
||||
ui.add_space(12.0);
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let mut close = false;
|
||||
let mut find_in_folder = false;
|
||||
let mut act: Option<&str> = None;
|
||||
crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::new("dialog-find-font"), -40.0, 22, |ui: &mut Ui| {
|
||||
crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::unique("dialog-find-font"), -40.0, 22, |ui: &mut Ui| {
|
||||
ui.set_width(380.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Find Font"));
|
||||
ui.add_space(10.0);
|
||||
|
||||
@@ -122,11 +122,11 @@ pub(crate) struct RootGroup {
|
||||
|
||||
/// The dock's groups as last drawn, top to bottom.
|
||||
pub(crate) fn root_groups_id() -> Id {
|
||||
Id::new("floating-root-groups")
|
||||
Id::unique("floating-root-groups")
|
||||
}
|
||||
|
||||
fn move_id() -> Id {
|
||||
Id::new("floating-move")
|
||||
Id::unique("floating-move")
|
||||
}
|
||||
|
||||
fn suppressed_id() -> Id {
|
||||
@@ -140,29 +140,29 @@ fn gesture_allowed(ctx: &egui::Context) -> bool {
|
||||
|
||||
/// The dock (tabbed group and icon column) as last drawn: a group dropped there docks.
|
||||
pub(crate) fn dock_rect_id() -> Id {
|
||||
Id::new("floating-dock-rect")
|
||||
Id::unique("floating-dock-rect")
|
||||
}
|
||||
|
||||
/// The strip along the window's left edge where the Tools panel docks, as last laid out.
|
||||
/// The icon column's bounds (egui temp memory, one frame old), where panels without a tab in the
|
||||
/// tabbed group go back to when docked.
|
||||
pub(crate) fn icons_rect_id() -> Id {
|
||||
Id::new("floating-icons-rect")
|
||||
Id::unique("floating-icons-rect")
|
||||
}
|
||||
|
||||
pub(crate) fn tools_zone_id() -> Id {
|
||||
Id::new("floating-tools-zone")
|
||||
Id::unique("floating-tools-zone")
|
||||
}
|
||||
|
||||
/// The area of the floating group whose first panel is `first` (of the set it heads).
|
||||
pub fn area_id(first: &str) -> Id {
|
||||
Id::new(("floating-panels", first))
|
||||
Id::unique(("floating-panels", first))
|
||||
}
|
||||
|
||||
/// The floating group whose first panel is `first`, as last drawn (egui temp memory): in a set,
|
||||
/// from the bar above its tabs to its panel's bottom.
|
||||
pub(crate) fn group_rect_id(first: &str) -> Id {
|
||||
Id::new(("floating-group-rect", first))
|
||||
Id::unique(("floating-group-rect", first))
|
||||
}
|
||||
|
||||
/// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel (a
|
||||
@@ -441,7 +441,7 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin
|
||||
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue };
|
||||
let Some(mut r) = ctx
|
||||
.data(|d| d.get_temp::<Rect>(group_rect_id(first)))
|
||||
.or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())))))
|
||||
.or_else(|| ctx.memory(|m| m.area_rect(Id::unique("vectorcraft-panel-docking").with(("floating", &first.to_string())))))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -450,7 +450,7 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin
|
||||
// available just outside the window. Dock columns reserve their own panel space.
|
||||
if g.docked.is_none() && r.contains(at) {
|
||||
let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) {
|
||||
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||||
Id::unique("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||||
} else {
|
||||
let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first());
|
||||
area_id(lead.map_or(first, String::as_str))
|
||||
@@ -458,7 +458,7 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin
|
||||
let moving_areas: Vec<Id> = moving
|
||||
.iter()
|
||||
.filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first()))
|
||||
.flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))])
|
||||
.flat_map(|panel| [area_id(panel), Id::unique("vectorcraft-panel-docking").with(("floating", panel))])
|
||||
.collect();
|
||||
let owner = ctx.memory(|memory| {
|
||||
let layers: Vec<_> = memory.layer_ids().collect();
|
||||
@@ -538,21 +538,21 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin
|
||||
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
|
||||
pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) {
|
||||
let t = Tokens::get(ctx);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::unique("floating-drop-zone")));
|
||||
painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// Light up the tabs a drop stacks the moved panels with, over the floating and docked groups.
|
||||
fn tab_zone(ctx: &egui::Context, tabs: Rect) {
|
||||
let t = Tokens::get(ctx);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-tabs")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("floating-drop-tabs")));
|
||||
painter.rect(tabs.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.25), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// Show where a drop puts the moved groups in a set: a line over the floating groups.
|
||||
fn insertion_line(ctx: &egui::Context, line: Rect) {
|
||||
let t = Tokens::get(ctx);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-line")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("floating-drop-line")));
|
||||
painter.rect_filled(line, CornerRadius::same(2), t.accent);
|
||||
}
|
||||
|
||||
@@ -855,7 +855,7 @@ pub fn dock_column(ui: &mut UiState, set: &[usize], slot: u32) {
|
||||
/// The dock columns, as last drawn (egui temp memory), from the one next to the icon column
|
||||
/// leftwards.
|
||||
pub(crate) fn columns_id() -> Id {
|
||||
Id::new("dock-columns")
|
||||
Id::unique("dock-columns")
|
||||
}
|
||||
|
||||
/// The sets docked as columns, by their column number (next to the icon column first).
|
||||
@@ -880,7 +880,7 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec
|
||||
let groups = shown_groups(&app.ui, &set);
|
||||
let Some(first) = groups.first().and_then(|g| g.ids.first().copied()) else { continue };
|
||||
let natural = groups.iter().flat_map(|g| g.ids.iter()).map(|id| dock::panel_width(id)).fold(200.0, f32::max);
|
||||
let panel = egui::Panel::right(Id::new(("dock-column", first)))
|
||||
let panel = egui::Panel::right(("dock-column", first))
|
||||
.resizable(true)
|
||||
.default_size(natural)
|
||||
.size_range(200.0..=520.0)
|
||||
@@ -891,7 +891,8 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover());
|
||||
ui.painter().rect_filled(bar, 0.0, t.tab_strip);
|
||||
let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE));
|
||||
let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("column-bar"), Sense::drag());
|
||||
let grip =
|
||||
ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.scope_id().with("column-bar"), Sense::drag());
|
||||
for k in 0..2 {
|
||||
let y = bar.center().y - 1.5 + k as f32 * 3.0;
|
||||
ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled));
|
||||
@@ -902,7 +903,7 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec
|
||||
action = Some(Action::Undock(gi, at - bar.min));
|
||||
}
|
||||
grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
|
||||
let x = ui.interact(close, ui.id().with("column-close"), Sense::click());
|
||||
let x = ui.interact(close, ui.scope_id().with("column-close"), Sense::click());
|
||||
icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim });
|
||||
if x.on_hover_text(tl!("Put back in the dock")).clicked() {
|
||||
action = Some(Action::Close(set.clone()));
|
||||
@@ -971,7 +972,7 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover());
|
||||
ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip);
|
||||
let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE));
|
||||
let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag());
|
||||
let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.scope_id().with("title"), Sense::drag());
|
||||
for k in 0..2 {
|
||||
let y = bar.center().y - 1.5 + k as f32 * 3.0;
|
||||
ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled));
|
||||
@@ -980,7 +981,7 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action
|
||||
*action = Some(Action::Move(lead_active, pos));
|
||||
}
|
||||
grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move"));
|
||||
let x = ui.interact(close, ui.id().with("close"), Sense::click());
|
||||
let x = ui.interact(close, ui.scope_id().with("close"), Sense::click());
|
||||
icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim });
|
||||
if x.on_hover_text(tl!("Put back in the dock")).clicked() {
|
||||
*action = Some(Action::Close(set.to_vec()));
|
||||
@@ -1052,7 +1053,7 @@ fn draw_groups(
|
||||
if rest.width() > 0.0 {
|
||||
// Click as well as drag, so a press there no longer starts the move at once and a
|
||||
// double-click can collapse the group.
|
||||
let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::click_and_drag());
|
||||
let resp = ui.interact(rest, ui.scope_id().with(("strip", g.gi)), Sense::click_and_drag());
|
||||
if resp.double_clicked() {
|
||||
*action = Some(Action::Collapse(g.gi));
|
||||
} else if resp.drag_started() {
|
||||
@@ -1327,7 +1328,7 @@ mod tests {
|
||||
fn group(&self, first: &str) -> Rect {
|
||||
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
|
||||
let id = if self.app.ui.docking.is_some() && !native {
|
||||
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||||
Id::unique("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||||
} else {
|
||||
area_id(first)
|
||||
};
|
||||
@@ -1355,7 +1356,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn shared_tab(&self, panel: &str) -> Rect {
|
||||
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect
|
||||
self.ctx.read_response(Id::unique("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect
|
||||
}
|
||||
|
||||
/// The dock's tabs, left to right.
|
||||
@@ -1366,7 +1367,7 @@ mod tests {
|
||||
.panels()
|
||||
.into_iter()
|
||||
.filter_map(|panel| {
|
||||
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect)
|
||||
self.ctx.read_response(Id::unique("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -1534,7 +1535,7 @@ mod tests {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("window.panel", json!({"panel": "swatches"})).unwrap();
|
||||
h.settle();
|
||||
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
|
||||
let flyout = h.ctx.memory(|m| m.area_rect(Id::unique("icon-panel"))).unwrap();
|
||||
let to = pos2(500.0, 400.0);
|
||||
h.drag(flyout.left_top() + vec2(20.0, 13.0), to);
|
||||
assert_eq!((h.groups(), h.app.ui.open_panel.as_deref()), (vec![vec!["swatches"]], None));
|
||||
@@ -1551,7 +1552,7 @@ mod tests {
|
||||
h.app.run("window.panel", json!({"panel":"swatches"})).unwrap();
|
||||
h.settle();
|
||||
let original = h.app.ui.clone();
|
||||
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
|
||||
let flyout = h.ctx.memory(|m| m.area_rect(Id::unique("icon-panel"))).unwrap();
|
||||
let to = pos2(500.0, 400.0);
|
||||
h.hold(flyout.left_top() + vec2(20.0, 13.0), to);
|
||||
assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into())));
|
||||
@@ -1614,7 +1615,7 @@ mod tests {
|
||||
let to = pos2(400.0, 300.0);
|
||||
h.drag(title, to);
|
||||
let pos = h.app.ui.toolbar_pos.expect("the Tools panel floats");
|
||||
let float = h.ctx.memory(|m| m.area_rect(Id::new("toolbar-floating"))).unwrap();
|
||||
let float = h.ctx.memory(|m| m.area_rect(Id::unique("toolbar-floating"))).unwrap();
|
||||
assert!(float.contains(to) && (pos2(pos[0], pos[1]) - float.min).length() < 1.0, "under the pointer: {float:?}");
|
||||
// Back on the window's left edge.
|
||||
let edge = pos2(10.0, 400.0);
|
||||
|
||||
@@ -245,7 +245,7 @@ pub fn font_menu(
|
||||
sample: Option<&str>,
|
||||
look: MenuLook,
|
||||
) -> Option<FontPick> {
|
||||
let state_id = ui.id().with(&id).with("font-menu");
|
||||
let state_id = ui.scope_id().with(&id).with("font-menu");
|
||||
let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
|
||||
let mut drawn = false;
|
||||
let picked = widgets::combo(ui, &id, current, width, true, |ui| {
|
||||
@@ -522,7 +522,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
||||
}
|
||||
}
|
||||
});
|
||||
st.scrolled = shown.state.offset.y;
|
||||
st.scrolled = shown.state.clamped_offset().y;
|
||||
if let Some(f) = toggled {
|
||||
if st.expanded.contains(&f) {
|
||||
st.expanded.retain(|e| *e != f);
|
||||
@@ -721,7 +721,7 @@ const PREVIEWING: &str = "font-previewing";
|
||||
|
||||
/// Where the open font menu was drawn last: (pass, its popup's layer).
|
||||
fn open_id() -> egui::Id {
|
||||
egui::Id::new("font-menu-open")
|
||||
egui::Id::unique("font-menu-open")
|
||||
}
|
||||
|
||||
/// Undo a preview whose menu is gone (its panel wasn't drawn: the text was deselected, the panel
|
||||
@@ -885,7 +885,7 @@ mod tests {
|
||||
for _ in 0..3 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
super::list(&mut child, egui::Id::new("fonts"), &mut st, "", None, look);
|
||||
super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
stars = out
|
||||
@@ -956,7 +956,7 @@ mod tests {
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
picked = super::list(
|
||||
ui,
|
||||
egui::Id::new("recent-test"),
|
||||
egui::Id::unique("recent-test"),
|
||||
&mut st,
|
||||
&names[2],
|
||||
None,
|
||||
@@ -973,7 +973,14 @@ mod tests {
|
||||
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }).to_vec()
|
||||
};
|
||||
assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None)));
|
||||
assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[0].clone(), None)), "skip Fonts heading");
|
||||
// Other tests add fonts to the process-wide database while this one runs, so the first
|
||||
// family under the Fonts heading need not be the one this test's earlier snapshot named.
|
||||
// Read it from the rows this frame built: the highlight must skip the heading and land on
|
||||
// that family, not on a heading or a row from the recent section.
|
||||
let (picked, rows) = frame(key(egui::Key::ArrowDown), &recent);
|
||||
assert_eq!(rows.get(3), Some(&Row::Heading("Fonts")), "{rows:?}");
|
||||
let (first, style) = rows.get(4).and_then(Row::pick).expect("a family below the Fonts heading");
|
||||
assert_eq!(picked, Some(super::FontPick::Preview(first, style)), "skip Fonts heading");
|
||||
assert_eq!(frame(key(egui::Key::ArrowUp), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None)));
|
||||
// Updating recent history while the menu is open must not retarget the highlight.
|
||||
let updated = vec![names[1].clone()];
|
||||
@@ -1003,7 +1010,7 @@ mod tests {
|
||||
|ui| {
|
||||
pick = super::list(
|
||||
ui,
|
||||
egui::Id::new("filtered-recent"),
|
||||
egui::Id::unique("filtered-recent"),
|
||||
&mut st,
|
||||
&names[2],
|
||||
None,
|
||||
@@ -1041,7 +1048,7 @@ mod tests {
|
||||
|ui| {
|
||||
super::list(
|
||||
ui,
|
||||
egui::Id::new("recent-at-top"),
|
||||
egui::Id::unique("recent-at-top"),
|
||||
&mut st,
|
||||
current,
|
||||
None,
|
||||
@@ -1110,7 +1117,7 @@ mod tests {
|
||||
|ui| {
|
||||
super::list(
|
||||
ui,
|
||||
egui::Id::new("variable-filter"),
|
||||
egui::Id::unique("variable-filter"),
|
||||
st,
|
||||
"Source Sans 3",
|
||||
None,
|
||||
|
||||
@@ -37,7 +37,7 @@ fn modes() -> [(&'static str, Glyph, &'static str); 3] {
|
||||
|
||||
/// Where the widget was last drawn (none while it isn't shown).
|
||||
pub(crate) fn rect(ctx: &egui::Context) -> Option<Rect> {
|
||||
let id = egui::Id::new(TOOL);
|
||||
let id = egui::Id::unique(TOOL);
|
||||
ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten())
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) {
|
||||
let mode = opts["mode"].as_str().unwrap_or("free").to_string();
|
||||
let constrain = opts["constrain"].as_bool().unwrap_or(false);
|
||||
let mut set: Option<(&str, Value)> = None;
|
||||
egui::Area::new(egui::Id::new(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| {
|
||||
egui::Area::new(egui::Id::unique(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.button_border))
|
||||
|
||||
@@ -42,7 +42,7 @@ impl GraphicsLoss {
|
||||
/// context's.
|
||||
pub(crate) fn context_tag(ctx: &egui::Context) -> u64 {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
let id = egui::Id::new("vectorcraft-context-tag");
|
||||
let id = egui::Id::unique("vectorcraft-context-tag");
|
||||
if let Some(tag) = ctx.data(|d| d.get_temp::<u64>(id)) {
|
||||
return tag;
|
||||
}
|
||||
@@ -105,14 +105,22 @@ impl VectorcraftApp {
|
||||
/// theme, the canvas, the place cursor's thumbnails and every [`TexCache`].
|
||||
pub(crate) fn adopt_context(&mut self, ctx: &egui::Context) {
|
||||
let tag = context_tag(ctx);
|
||||
self.ui_ctx = Some(ctx.clone());
|
||||
if self.context == tag {
|
||||
return;
|
||||
}
|
||||
if self.context != 0 {
|
||||
log::info!("vectorcraft: new graphics context: uploading the UI again");
|
||||
forget_textures();
|
||||
// The IME tracker's pass numbers are per context. A new context starts at pass 0; only
|
||||
// an explicit teardown guarantees it cannot look continuous with the old context's
|
||||
// last observed pass and reuse its owner/baseline generation.
|
||||
self.ime_context.end_viewport(ctx.viewport_id());
|
||||
self.styled = false;
|
||||
self.fonts_ready = false;
|
||||
// Shortcut input is keyed by context identity. Preserve a first pass already
|
||||
// handled for this new context before UI setup, or toggle keys execute twice.
|
||||
self.injected_settle_hook = false;
|
||||
self.ui_fonts = Default::default();
|
||||
self.canvas.texture = None;
|
||||
self.canvas.key = None;
|
||||
|
||||
@@ -5478,3 +5478,4 @@ axis Y Axis Y ос
|
||||
Rectangular Grid Tool Options Опции на инструмента „Правоъгълна мрежа“
|
||||
Polar Grid Tool Options Опции на инструмента „Полярна мрежа“
|
||||
Fill Line Запълване на линия
|
||||
Scale Presets Пресети за мащаб
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5550,3 +5550,6 @@ axis Y Axis osa Y
|
||||
Rectangular Grid Tool Options Možnosti nástroje Obdélníková mřížka
|
||||
Polar Grid Tool Options Možnosti nástroje Polární mřížka
|
||||
Fill Line Výplň úsečky
|
||||
Scale Presets Přednastavení měřítka
|
||||
@msg a {width}×{height} screenshot's byte count overflows počet bajtů snímku {width}×{height} přeteče
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot okno {what} ({points} bodů × {scale} DPI) nemůže určit velikost snímku
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5494,3 +5494,4 @@ axis Y Axis Y-akse
|
||||
Rectangular Grid Tool Options Indstillinger for rektangulært gitterværktøj
|
||||
Polar Grid Tool Options Indstillinger for polært gitterværktøj
|
||||
Fill Line Udfyld linje
|
||||
Scale Presets Skaleringsforudindstillinger
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5132,3 +5132,6 @@ axis Y Axis Y-Achse
|
||||
Rectangular Grid Tool Options Optionen für das Rechteckiges-Raster-Werkzeug
|
||||
Polar Grid Tool Options Optionen für das Radiales-Raster-Werkzeug
|
||||
Fill Line Linie füllen
|
||||
Scale Presets Skalierungsvorgaben
|
||||
@msg a {width}×{height} screenshot's byte count overflows die Byteanzahl eines {width}×{height}-Screenshots läuft über
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot die {what} des Fensters ({points} Punkte × {scale} DPI) kann keinen Screenshot bemessen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5502,3 +5502,6 @@ axis Y Axis Άξονας Y
|
||||
Rectangular Grid Tool Options Επιλογές εργαλείου ορθογώνιου πλέγματος
|
||||
Polar Grid Tool Options Επιλογές εργαλείου πολικού πλέγματος
|
||||
Fill Line Γέμισμα γραμμής
|
||||
Scale Presets Προεπιλογές κλίμακας
|
||||
@msg a {width}×{height} screenshot's byte count overflows το πλήθος byte ενός στιγμιότυπου {width}×{height} υπερχειλίζει
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot το {what} του παραθύρου ({points} σημεία × {scale} DPI) δεν μπορεί να καθορίσει μέγεθος στιγμιότυπου
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2069 and column 19.
|
@@ -5492,3 +5492,6 @@ axis Y Axis Eje Y
|
||||
Rectangular Grid Tool Options Opciones de la herramienta Cuadrícula rectangular
|
||||
Polar Grid Tool Options Opciones de la herramienta Cuadrícula polar
|
||||
Fill Line Rellenar línea
|
||||
Scale Presets Ajustes predefinidos de escala
|
||||
@msg a {width}×{height} screenshot's byte count overflows el número de bytes de una captura de {width}×{height} se desborda
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot el {what} de la ventana ({points} puntos × {scale} DPI) no puede dimensionar una captura
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5581,3 +5581,6 @@ axis Y Axis Y-akseli
|
||||
Rectangular Grid Tool Options Suorakulmaisen ruudukon työkalun asetukset
|
||||
Polar Grid Tool Options Napakoordinaattiruudukon työkalun asetukset
|
||||
Fill Line Täytä viiva
|
||||
Scale Presets Skaalauksen esiasetukset
|
||||
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} kuvakaappauksen tavumäärä ylittää rajan
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ikkunan {what} ({points} pistettä × {scale} DPI) ei voi määrittää kuvakaappauksen kokoa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5549,3 +5549,6 @@ axis Y Axis Axe Y
|
||||
Rectangular Grid Tool Options Options de l’outil Grille rectangulaire
|
||||
Polar Grid Tool Options Options de l’outil Grille à coordonnées polaires
|
||||
Fill Line Remplir le trait
|
||||
Scale Presets Préréglages de mise à l'échelle
|
||||
@msg a {width}×{height} screenshot's byte count overflows le nombre d'octets d'une capture {width}×{height} dépasse la limite
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} de la fenêtre ({points} points × {scale} DPI) ne permet pas de dimensionner une capture
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5536,3 +5536,6 @@ axis Y Axis Y tengely
|
||||
Rectangular Grid Tool Options Téglalaprács beállításai
|
||||
Polar Grid Tool Options Polárrács beállításai
|
||||
Fill Line Szakasz kitöltése
|
||||
Scale Presets Méretezési beállítások
|
||||
@msg a {width}×{height} screenshot's byte count overflows a(z) {width}×{height} képernyőkép bájtszáma túlcsordul
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot az ablak {what} ({points} pont × {scale} DPI) nem tud képernyőkép-méretet adni
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5494,3 +5494,4 @@ axis Y Axis Sumbu Y
|
||||
Rectangular Grid Tool Options Opsi alat kisi persegi panjang
|
||||
Polar Grid Tool Options Opsi alat kisi polar
|
||||
Fill Line Isi garis
|
||||
Scale Presets Preset skala
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3840 and column 19.
|
@@ -5166,3 +5166,6 @@ axis Y Axis Asse Y
|
||||
Rectangular Grid Tool Options Opzioni strumento Griglia rettangolare
|
||||
Polar Grid Tool Options Opzioni strumento Griglia polare
|
||||
Fill Line Riempi linea
|
||||
Scale Presets Preimpostazioni di scala
|
||||
@msg a {width}×{height} screenshot's byte count overflows il numero di byte di uno screenshot {width}×{height} va in overflow
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} della finestra ({points} punti × {scale} DPI) non può dimensionare uno screenshot
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5228,3 +5228,6 @@ axis Y Axis Y 軸
|
||||
Rectangular Grid Tool Options 長方形グリッドツールオプション
|
||||
Polar Grid Tool Options 同心円グリッドツールオプション
|
||||
Fill Line 線を塗りつぶす
|
||||
Scale Presets 拡大縮小のプリセット
|
||||
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} のスクリーンショットのバイト数がオーバーフローします
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ウィンドウの {what}({points} ポイント × {scale} DPI)ではスクリーンショットのサイズを決められません
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5466,3 +5466,6 @@ axis Y Axis Y ღერძი
|
||||
Rectangular Grid Tool Options მართკუთხა ბადის ხელსაწყოს პარამეტრები
|
||||
Polar Grid Tool Options პოლარული ბადის ხელსაწყოს პარამეტრები
|
||||
Fill Line ხაზის შევსება
|
||||
Scale Presets მასშტაბის წინასწარ დაყენებული მნიშვნელობები
|
||||
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ეკრანის სურათის ბაიტების რაოდენობა გადაივსო
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ფანჯრის {what} ({points} პუნქტი × {scale} DPI) ეკრანის სურათის ზომას ვერ განსაზღვრავს
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5129,3 +5129,6 @@ axis Y Axis Y축
|
||||
Rectangular Grid Tool Options 사각형 격자 도구 옵션
|
||||
Polar Grid Tool Options 극좌표 격자 도구 옵션
|
||||
Fill Line 선 채우기
|
||||
Scale Presets 크기 조절 사전 설정
|
||||
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 스크린샷의 바이트 수가 넘칩니다
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 창의 {what}({points}포인트 × {scale} DPI)로는 스크린샷 크기를 정할 수 없습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5538,3 +5538,4 @@ axis Y Axis Y-as
|
||||
Rectangular Grid Tool Options Opties rechthoekig-rastergereedschap
|
||||
Polar Grid Tool Options Opties polair-rastergereedschap
|
||||
Fill Line Lijn vullen
|
||||
Scale Presets Schaalvoorinstellingen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5402,3 +5402,6 @@ axis Y Axis Oś Y
|
||||
Rectangular Grid Tool Options Opcje narzędzia Siatka prostokątna
|
||||
Polar Grid Tool Options Opcje narzędzia Siatka biegunowa
|
||||
Fill Line Wypełnij odcinek
|
||||
Scale Presets Ustawienia wstępne skali
|
||||
@msg a {width}×{height} screenshot's byte count overflows liczba bajtów zrzutu ekranu {width}×{height} przekracza limit
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} okna ({points} punktów × {scale} DPI) nie pozwala ustalić rozmiaru zrzutu ekranu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5467,3 +5467,6 @@ axis Y Axis Eixo Y
|
||||
Rectangular Grid Tool Options Opções da Ferramenta Grade Retangular
|
||||
Polar Grid Tool Options Opções da Ferramenta Grade Polar
|
||||
Fill Line Preencher linha
|
||||
Scale Presets Predefinições de escala
|
||||
@msg a {width}×{height} screenshot's byte count overflows a contagem de bytes de uma captura {width}×{height} transborda
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot o {what} da janela ({points} pontos × {scale} DPI) não pode dimensionar uma captura
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -5162,3 +5162,6 @@ axis Y Axis Ось Y
|
||||
Rectangular Grid Tool Options Параметры инструмента «Прямоугольная сетка»
|
||||
Polar Grid Tool Options Параметры инструмента «Полярная сетка»
|
||||
Fill Line Заливка отрезка
|
||||
Scale Presets Наборы масштаба
|
||||
@msg a {width}×{height} screenshot's byte count overflows количество байтов снимка {width}×{height} переполняется
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} окна ({points} пунктов × {scale} DPI) не позволяет задать размер снимка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5574,3 +5574,6 @@ axis Y Axis Y-axel
|
||||
Rectangular Grid Tool Options Alternativ för verktyget för rektangulärt rutnät
|
||||
Polar Grid Tool Options Alternativ för verktyget för polärt rutnät
|
||||
Fill Line Fyll linje
|
||||
Scale Presets Skalningsförval
|
||||
@msg a {width}×{height} screenshot's byte count overflows byteantalet för en skärmbild på {width}×{height} överstiger gränsen
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot fönstrets {what} ({points} punkter × {scale} DPI) kan inte bestämma en skärmbilds storlek
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1239,8 +1239,9 @@ fn latin_script_glyphs_are_available_without_system_fonts() {
|
||||
let families: Vec<_> = fonts.definitions().families.iter().map(|(f, stack)| (f.clone(), stack.first().cloned())).collect();
|
||||
for (family, first) in families {
|
||||
let first = first.unwrap();
|
||||
let mut font = fonts.fonts.font(&family);
|
||||
let chars = font.characters();
|
||||
// Public 0.37 view: the family's characters with the fonts that have each (the old
|
||||
// `fonts.font(...).characters()` reached a now-private field).
|
||||
let chars = fonts.characters(&family);
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖßőűŐŰąćęłńśźżĄĆĘŁŃŚŹŻåÅ\u{a0}".chars()
|
||||
{
|
||||
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
|
||||
@@ -1261,8 +1262,9 @@ fn cyrillic_glyphs_are_available_without_system_fonts() {
|
||||
let families: Vec<_> = fonts.definitions().families.iter().map(|(f, stack)| (f.clone(), stack.first().cloned())).collect();
|
||||
for (family, first) in families {
|
||||
let first = first.unwrap();
|
||||
let mut font = fonts.fonts.font(&family);
|
||||
let chars = font.characters();
|
||||
// Public 0.37 view: the family's characters with the fonts that have each (the old
|
||||
// `fonts.font(...).characters()` reached a now-private field).
|
||||
let chars = fonts.characters(&family);
|
||||
for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯґєіїҐЄІЇ«»„“…–’".chars()
|
||||
{
|
||||
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
|
||||
|
||||
@@ -5513,3 +5513,6 @@ axis Y Axis Y ekseni
|
||||
Rectangular Grid Tool Options Dikdörtgen Izgara Aracı Seçenekleri
|
||||
Polar Grid Tool Options Yuvarlak Izgara Aracı Seçenekleri
|
||||
Fill Line Çizgiyi doldur
|
||||
Scale Presets Ölçek ön ayarları
|
||||
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ekran görüntüsünün bayt sayısı taşıyor
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot pencerenin {what} ({points} nokta × {scale} DPI) bir ekran görüntüsünü boyutlandıramaz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5146,3 +5146,6 @@ axis Y Axis Вісь Y
|
||||
Rectangular Grid Tool Options Параметри інструменту «Прямокутна сітка»
|
||||
Polar Grid Tool Options Параметри інструменту «Полярна сітка»
|
||||
Fill Line Заливка відрізка
|
||||
Scale Presets Набори масштабу
|
||||
@msg a {width}×{height} screenshot's byte count overflows кількість байтів знімка {width}×{height} переповнюється
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} вікна ({points} пунктів × {scale} DPI) не дозволяє задати розмір знімка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 749 and column 40.
|
@@ -5146,3 +5146,6 @@ axis Y Axis Trục Y
|
||||
Rectangular Grid Tool Options Tùy chọn công cụ Lưới chữ nhật
|
||||
Polar Grid Tool Options Tùy chọn công cụ Lưới cực
|
||||
Fill Line Tô đoạn thẳng
|
||||
Scale Presets Cài đặt sẵn tỉ lệ
|
||||
@msg a {width}×{height} screenshot's byte count overflows số byte của ảnh chụp {width}×{height} bị tràn
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} của cửa sổ ({points} điểm × {scale} DPI) không thể định cỡ ảnh chụp
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4054 and column 10.
|
@@ -5389,3 +5389,6 @@ axis Y Axis Y 轴
|
||||
Rectangular Grid Tool Options 矩形网格工具选项
|
||||
Polar Grid Tool Options 极坐标网格工具选项
|
||||
Fill Line 填充线段
|
||||
Scale Presets 缩放预设
|
||||
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 截图的字节数溢出
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 窗口的{what}({points} 点 × {scale} DPI)无法确定截图大小
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5185,3 +5185,6 @@ axis Y Axis Y 軸
|
||||
Rectangular Grid Tool Options 矩形格線工具選項
|
||||
Polar Grid Tool Options 極座標格點工具選項
|
||||
Fill Line 填色線段
|
||||
Scale Presets 縮放預設集
|
||||
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 螢幕擷取畫面的位元組數溢出
|
||||
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 視窗的{what}({points} 點 × {scale} DPI)無法決定螢幕擷取畫面的大小
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -0,0 +1,799 @@
|
||||
//! VectorCraft's outgoing IME producer: the Type tool's committed logical text, its stable owner
|
||||
//! and context generation, and the explicit `InputMethod`/`Other` cause.
|
||||
//!
|
||||
//! The shared [`egui::ime_context`] helper does the lifecycle bookkeeping. This module is the thin
|
||||
//! app adapter the helper asks for: it names the edit target from the engine's document uid and
|
||||
//! TypeTool edit session, copies only a bounded committed window (never the whole document), and
|
||||
//! turns accepted IME mutations into receipts around the exact engine call that applied them.
|
||||
|
||||
use egui::ime_context::{CommittedText, ImeContextUpdate, ImeMutationStamp, ImeObservation, ImeTarget};
|
||||
use egui::output::{IMEOutput, MAX_IME_SURROUNDING_TEXT_BYTES};
|
||||
use egui::{Context, Id};
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
use vectorcraft_tools::ImeSurrounding;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// The same byte limit the fork's [`egui::output::IMESurroundingText::from_text`] enforces. Asking
|
||||
/// the engine for exactly this window avoids cloning a large document just to discard most of it.
|
||||
pub(crate) const SURROUNDING_LIMIT: usize = MAX_IME_SURROUNDING_TEXT_BYTES;
|
||||
|
||||
/// Incoming selection provenance, never part of the immutable native target identity. Use the
|
||||
/// tool's actual offsets: the outgoing logical snapshot collapses offsets inside marked text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct IncomingSelection {
|
||||
target: ImeTarget,
|
||||
anchor: u64,
|
||||
caret: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum IncomingComposition {
|
||||
Active(IncomingSelection),
|
||||
/// An empty preedit cleared the preview; its separate terminal mutation is still pending.
|
||||
TerminalPending(IncomingSelection),
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OutputContext {
|
||||
owner: Id,
|
||||
generation: u64,
|
||||
purpose: egui::IMEPurpose,
|
||||
}
|
||||
|
||||
impl From<&IMEOutput> for OutputContext {
|
||||
fn from(output: &IMEOutput) -> Self {
|
||||
Self { owner: output.owner, generation: output.context_generation, purpose: output.purpose }
|
||||
}
|
||||
}
|
||||
|
||||
/// A guard can remain blocked without an acceptable token (suspension/allocation failure).
|
||||
/// The destination is separate from the old composition whose preview was settled.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ResetGuard {
|
||||
request: Option<egui::ImeResetId>,
|
||||
context: u64,
|
||||
destination: Option<IncomingSelection>,
|
||||
committed: Option<ImeMutationStamp>,
|
||||
output: Option<OutputContext>,
|
||||
suspended: bool,
|
||||
deferred: bool,
|
||||
/// Settled before the UI ran: this pass's token is retired and only the post-UI finalizer,
|
||||
/// after the descriptor is confirmed, may bind the replacement.
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
fn mode_id(viewport: egui::ViewportId) -> Id {
|
||||
Id::unique(("vectorcraft.ime.in-ui-pass", viewport))
|
||||
}
|
||||
fn output_id(viewport: egui::ViewportId) -> Id {
|
||||
Id::unique(("vectorcraft.ime.pass-output", viewport))
|
||||
}
|
||||
|
||||
/// Install before the host's first pass. Callbacks borrow their own Ui's Context, never capture it.
|
||||
pub(crate) fn install_hooks(ctx: &Context) {
|
||||
let installed = Id::unique("vectorcraft.ime.mode-hooks-installed");
|
||||
let first = ctx.data_mut(|data| {
|
||||
if data.get_temp::<bool>(installed).unwrap_or(false) {
|
||||
false
|
||||
} else {
|
||||
data.insert_temp(installed, true);
|
||||
true
|
||||
}
|
||||
});
|
||||
if !first {
|
||||
return;
|
||||
}
|
||||
ctx.on_begin_pass(
|
||||
"VectorCraft IME pass begin",
|
||||
std::sync::Arc::new(|ui| {
|
||||
let ctx = ui.ctx();
|
||||
let id = mode_id(ctx.viewport_id());
|
||||
ctx.data_mut(|data| data.insert_temp(id, true));
|
||||
}),
|
||||
);
|
||||
ctx.on_end_pass(
|
||||
"VectorCraft IME pass end",
|
||||
std::sync::Arc::new(|ui| {
|
||||
let ctx = ui.ctx();
|
||||
let id = mode_id(ctx.viewport_id());
|
||||
ctx.data_mut(|data| data.insert_temp(id, false));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// The base eligibility every custom-descriptor consumer shares: a hidden viewport, the Home
|
||||
/// screen, a modal dialog, the palette, a file sheet, an unfocused window, another egui keyboard
|
||||
/// owner or no Type-tool text edit all mean this pass publishes a stock descriptor or none. One
|
||||
/// helper so the pre-UI route test, the post-UI finalizer and the end-of-pass observation cannot
|
||||
/// drift apart; Home in particular is an ineligible route even though the Type tool keeps its
|
||||
/// target across it.
|
||||
fn pass_eligible(app: &VectorcraftApp, ctx: &Context) -> bool {
|
||||
!app.viewport_hidden(ctx)
|
||||
&& !crate::menus::home_showing(app)
|
||||
&& app.ui.dialog.is_none()
|
||||
&& !app.ui.palette_open
|
||||
&& !app.file_sheet_open()
|
||||
&& ctx.input(|input| input.focused)
|
||||
&& !ctx.egui_wants_keyboard_input()
|
||||
&& app.session.tool_wants_text()
|
||||
}
|
||||
|
||||
/// The one route test every allocation goes through: a request exists only when this pass can
|
||||
/// publish the custom descriptor that consumes it. egui keeps the latest request in `Memory` until
|
||||
/// such a descriptor arrives, so allocating on a hidden, Home, stock or geometry-less route would
|
||||
/// attach a foreign token to whatever stock field publishes next. An ineligible route retires the
|
||||
/// local guard instead; a later eligible activation allocates a fresh request.
|
||||
fn route_live(app: &mut VectorcraftApp, ctx: &Context) -> bool {
|
||||
if !pass_eligible(app, ctx) {
|
||||
return false;
|
||||
}
|
||||
let view = app.view_info();
|
||||
target(&mut app.session, view).is_some() && app.session.tool_ime_caret(view).is_some()
|
||||
}
|
||||
|
||||
fn request_guard(app: &mut VectorcraftApp, ctx: &Context, deferred: bool) -> bool {
|
||||
if !route_live(app, ctx) {
|
||||
suspend(app, ctx);
|
||||
return false;
|
||||
}
|
||||
let viewport = ctx.viewport_id();
|
||||
let destination = incoming_selection(app);
|
||||
let view = app.view_info();
|
||||
let committed = stamp(&mut app.session, view);
|
||||
let output = app.ime_resets.get(&viewport).and_then(|guard| guard.output);
|
||||
let request = ctx.request_ime_reset(viewport);
|
||||
#[cfg(test)]
|
||||
note_test_request(ctx);
|
||||
app.ime_resets.insert(
|
||||
viewport,
|
||||
ResetGuard {
|
||||
request,
|
||||
context: crate::graphics::context_tag(ctx),
|
||||
destination,
|
||||
committed,
|
||||
output,
|
||||
suspended: false,
|
||||
deferred,
|
||||
pending: false,
|
||||
},
|
||||
);
|
||||
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn request_count_id() -> Id {
|
||||
Id::unique("vectorcraft.ime.test-request-count")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn note_test_request(ctx: &Context) {
|
||||
ctx.data_mut(|data| {
|
||||
let count = data.get_temp::<u64>(request_count_id()).unwrap_or(0);
|
||||
data.insert_temp(request_count_id(), count.saturating_add(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// Tests read how many guards were requested since the last call. This makes an intermediate
|
||||
/// pre-settlement request visible even when the final token is stable.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_request_count(ctx: &Context) -> u64 {
|
||||
ctx.data_mut(|data| data.remove_temp::<u64>(request_count_id()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// True only while a pointer/navigation/command notice is waiting for its real later handler.
|
||||
/// Destination-dependent raw input must not mutate the old caret until that handler ran.
|
||||
pub(crate) fn deferred(app: &VectorcraftApp, ctx: &Context) -> bool {
|
||||
app.ime_resets.get(&ctx.viewport_id()).is_some_and(|guard| guard.deferred)
|
||||
}
|
||||
|
||||
/// Retire a live guard when this pass cannot publish the custom editor at all (the route lost
|
||||
/// its target or caret geometry, or the viewport is hidden). This runs before any reset marker is
|
||||
/// considered, so a marker for the old request cannot complete a route that no longer exists.
|
||||
pub(crate) fn suspend_if_route_lost(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let viewport = ctx.viewport_id();
|
||||
if !app.ime_resets.get(&viewport).is_some_and(|guard| !guard.suspended) {
|
||||
return;
|
||||
}
|
||||
if !route_live(app, ctx) {
|
||||
suspend(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn blocked(app: &VectorcraftApp, ctx: &Context) -> bool {
|
||||
app.ime_resets.contains_key(&ctx.viewport_id())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pending_request(app: &VectorcraftApp, ctx: &Context) -> Option<egui::ImeResetId> {
|
||||
app.ime_resets.get(&ctx.viewport_id()).and_then(|guard| guard.request)
|
||||
}
|
||||
|
||||
/// A notice precedes a later pointer/navigation/command handler. Retire the old request and keep
|
||||
/// its suffix closed until that handler has established the real destination. No request is
|
||||
/// allocated here: the final handler may still invalidate the route (a click that ends editing, a
|
||||
/// modal, a stock field), and a token allocated now would survive in egui's memory for the next
|
||||
/// descriptor. The eligible finalizer allocates for the confirmed destination instead.
|
||||
pub(crate) fn defer_interrupt(app: &mut VectorcraftApp, ctx: &Context) -> bool {
|
||||
let live = route_live(app, ctx);
|
||||
let was_blocked = blocked(app, ctx);
|
||||
// Settle with the old suffix still fenced off, but never allocate: the raw suffix after this
|
||||
// notice belongs to the old caret whether or not the final route is still a custom editor.
|
||||
let settled = interrupt_incoming_impl(app, ctx, Fence::Deferred);
|
||||
if settled || was_blocked {
|
||||
// The finalizer owns the token, but this pass must keep a guard so the old marker cannot
|
||||
// complete and so a confirmed final descriptor requests its replacement. A new guard here
|
||||
// has no request: `needs_replacement` sees `deferred` and allocates later.
|
||||
place_deferred_guard(app, ctx);
|
||||
}
|
||||
if !live {
|
||||
// No custom editor owns this pass. Keep the guard retired until an eligible activation,
|
||||
// but leave its stale latch in place so no old commit/preedit is accepted meanwhile.
|
||||
if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) {
|
||||
guard.request = None;
|
||||
guard.deferred = true;
|
||||
guard.suspended = true;
|
||||
}
|
||||
}
|
||||
settled || blocked(app, ctx)
|
||||
}
|
||||
|
||||
/// Retire the current request but keep the guard awaiting the finalizer's confirmed destination.
|
||||
fn place_deferred_guard(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let viewport = ctx.viewport_id();
|
||||
let destination = incoming_selection(app);
|
||||
let view = app.view_info();
|
||||
let committed = stamp(&mut app.session, view);
|
||||
match app.ime_resets.get_mut(&viewport) {
|
||||
Some(guard) => {
|
||||
guard.request = None;
|
||||
guard.deferred = true;
|
||||
guard.pending = false;
|
||||
}
|
||||
None => {
|
||||
let output = None;
|
||||
app.ime_resets.insert(
|
||||
viewport,
|
||||
ResetGuard {
|
||||
request: None,
|
||||
context: crate::graphics::context_tag(ctx),
|
||||
destination,
|
||||
committed,
|
||||
output,
|
||||
suspended: false,
|
||||
deferred: true,
|
||||
pending: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
||||
}
|
||||
|
||||
/// Pre-UI settlement. The old token is retired and the ordered latch is kept, but nothing is
|
||||
/// allocated: the pass's final descriptor does not exist yet, so a token bound here could outlive
|
||||
/// the route (a later palette/dialog/Home) in egui's memory. The post-UI finalizer, which confirms
|
||||
/// the descriptor, binds the replacement for `pending`.
|
||||
fn mark_pending(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let viewport = ctx.viewport_id();
|
||||
let destination = incoming_selection(app);
|
||||
let view = app.view_info();
|
||||
let committed = stamp(&mut app.session, view);
|
||||
match app.ime_resets.get_mut(&viewport) {
|
||||
Some(guard) => {
|
||||
guard.request = None;
|
||||
guard.deferred = false;
|
||||
guard.pending = true;
|
||||
}
|
||||
None => {
|
||||
app.ime_resets.insert(
|
||||
viewport,
|
||||
ResetGuard {
|
||||
request: None,
|
||||
context: crate::graphics::context_tag(ctx),
|
||||
destination,
|
||||
committed,
|
||||
output: None,
|
||||
suspended: false,
|
||||
deferred: false,
|
||||
pending: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
||||
}
|
||||
|
||||
fn suspend(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) {
|
||||
guard.request = None;
|
||||
guard.suspended = true;
|
||||
guard.deferred = false;
|
||||
guard.pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A close request ends this viewport's IME route without publishing a custom descriptor. Settle
|
||||
/// any preview without a token (a later stock field must not inherit one) and retire the guard so
|
||||
/// a cancelled close rearms on a fresh generation.
|
||||
pub(crate) fn close_viewport(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let viewport = ctx.viewport_id();
|
||||
interrupt_incoming_impl(app, ctx, Fence::Unbound);
|
||||
app.ime_compositions.remove(&viewport);
|
||||
suspend(app, ctx);
|
||||
}
|
||||
|
||||
/// Only this ordered raw control can lift an interrupted guard. Call-return and old terminals
|
||||
/// do not acknowledge it. A missing, suspended, deferred or pending guard is inert: in particular
|
||||
/// this must not synchronize an active composition into a fresh guard after `controls_without_editor`.
|
||||
pub(crate) fn complete(app: &mut VectorcraftApp, ctx: &Context, request: egui::ImeResetId) -> bool {
|
||||
let viewport = ctx.viewport_id();
|
||||
if !app.ime_resets.get(&viewport).is_some_and(|guard| !guard.suspended && !guard.deferred && !guard.pending) {
|
||||
return false;
|
||||
}
|
||||
// This runs before the UI: a changed composition settles `pending` and its finalizer, after
|
||||
// the descriptor is confirmed, binds any replacement token.
|
||||
sync_incoming_impl(app, ctx, Fence::Pending);
|
||||
let destination = incoming_selection(app);
|
||||
if !app.ime_resets.get(&viewport).is_some_and(|guard| {
|
||||
!guard.suspended
|
||||
&& !guard.deferred
|
||||
&& !guard.pending
|
||||
&& guard.request == Some(request)
|
||||
&& guard.context == crate::graphics::context_tag(ctx)
|
||||
&& request.viewport_id() == viewport
|
||||
&& guard.destination == destination
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
app.ime_resets.remove(&viewport);
|
||||
app.ime_compositions.remove(&viewport);
|
||||
app.ime_marked = None;
|
||||
if app.ime_resets.is_empty() {
|
||||
app.ime_discard = false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// This path owns controls only when the custom editor cannot process ANY raw mutations.
|
||||
/// Retire its activation before walking controls; leave the stock field's text/keys intact.
|
||||
pub(crate) fn controls_without_editor(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
suspend(app, ctx);
|
||||
let controls = ctx.input_mut(|input| {
|
||||
let mut controls = Vec::new();
|
||||
input.events.retain(|event| {
|
||||
if let egui::Event::ImeResetCompleted { request_id } = event {
|
||||
controls.push(*request_id);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
controls
|
||||
});
|
||||
for request in controls {
|
||||
complete(app, ctx, request);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
app.ime_caret_rect = None;
|
||||
let id = output_id(ctx.viewport_id());
|
||||
ctx.data_mut(|data| data.remove::<(u64, OutputContext)>(id));
|
||||
}
|
||||
|
||||
/// Every UI exit, including discarded/stock/no-geometry output, observes this pass's descriptor.
|
||||
/// Losing publication retires R1; reactivation allocates R2 even for the same model/caret.
|
||||
pub(crate) fn finish_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let viewport = ctx.viewport_id();
|
||||
let custom = ctx
|
||||
.data(|data| data.get_temp::<(u64, OutputContext)>(output_id(viewport)))
|
||||
.filter(|(pass, _)| *pass == ctx.cumulative_pass_nr_for(viewport))
|
||||
.map(|(_, key)| key);
|
||||
if !eligible(app, ctx, custom) {
|
||||
suspend(app, ctx);
|
||||
return;
|
||||
}
|
||||
// Only an eligible custom route may settle an active composition: doing this for a stock
|
||||
// field could allocate a request that core would attach to that field's descriptor.
|
||||
sync_incoming(app, ctx);
|
||||
let actual = ctx.output(|output| output.ime.as_ref().map(OutputContext::from));
|
||||
if !eligible_with(app, ctx, custom, actual) {
|
||||
suspend(app, ctx);
|
||||
return;
|
||||
}
|
||||
let destination = incoming_selection(app);
|
||||
let Some(custom) = custom else {
|
||||
suspend(app, ctx);
|
||||
return;
|
||||
};
|
||||
let replacement = app.ime_resets.get(&viewport).is_some_and(|guard| needs_replacement(guard, destination, custom));
|
||||
if replacement && !request_guard(app, ctx, false) {
|
||||
return;
|
||||
}
|
||||
if let Some(guard) = app.ime_resets.get_mut(&viewport) {
|
||||
guard.output = Some(custom);
|
||||
}
|
||||
}
|
||||
|
||||
fn eligible(app: &VectorcraftApp, ctx: &Context, custom: Option<OutputContext>) -> bool {
|
||||
let actual = ctx.output(|output| output.ime.as_ref().map(OutputContext::from));
|
||||
eligible_with(app, ctx, custom, actual)
|
||||
}
|
||||
|
||||
fn eligible_with(app: &VectorcraftApp, ctx: &Context, custom: Option<OutputContext>, actual: Option<OutputContext>) -> bool {
|
||||
custom.is_some() && actual == custom && pass_eligible(app, ctx)
|
||||
}
|
||||
|
||||
fn needs_replacement(guard: &ResetGuard, destination: Option<IncomingSelection>, custom: OutputContext) -> bool {
|
||||
guard.suspended || guard.deferred || guard.pending || guard.destination != destination || guard.output.is_some_and(|previous| previous != custom)
|
||||
}
|
||||
|
||||
/// A full pass calls logic before UI; absence at that intermediate point is not publication
|
||||
/// loss. Standalone run_logic has no fresh geometry and must retire a pending activation once.
|
||||
pub(crate) fn finish_logic(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let id = mode_id(ctx.viewport_id());
|
||||
if !ctx.data(|data| data.get_temp::<bool>(id).unwrap_or(false)) {
|
||||
// A logic-only iteration has no fresh custom descriptor: settle a changed preview without
|
||||
// ever allocating a request (in particular for a hidden window with an external edit).
|
||||
sync_incoming_unbound(app, ctx);
|
||||
suspend(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn incoming_selection(app: &mut VectorcraftApp) -> Option<IncomingSelection> {
|
||||
let view = app.view_info();
|
||||
let target = target(&mut app.session, view)?;
|
||||
let options = app.session.tool_options();
|
||||
Some(IncomingSelection { target, anchor: options.get("anchor")?.as_u64()?, caret: options.get("caret")?.as_u64()? })
|
||||
}
|
||||
|
||||
/// Record a successfully accepted preedit. Accepted IME deletes refresh this selection without
|
||||
/// invalidating the commit that belongs to them.
|
||||
pub(crate) fn remember_incoming(app: &mut VectorcraftApp, ctx: &Context, preedit_empty: Option<bool>) {
|
||||
let viewport = ctx.viewport_id();
|
||||
let Some(selection) = incoming_selection(app) else { return };
|
||||
let next = match (preedit_empty, app.ime_compositions.get(&viewport)) {
|
||||
(Some(false), _) | (None, Some(IncomingComposition::Active(_))) => IncomingComposition::Active(selection),
|
||||
(Some(true), _) | (None, Some(IncomingComposition::TerminalPending(_))) => IncomingComposition::TerminalPending(selection),
|
||||
_ => return,
|
||||
};
|
||||
app.ime_compositions.insert(viewport, next);
|
||||
}
|
||||
|
||||
/// How an incoming-composition settlement relates to a reset request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Fence {
|
||||
/// Settle and allocate a request for this pass's custom descriptor.
|
||||
Bound,
|
||||
/// Settle and close the raw suffix, but leave the token to the finalizer that confirms the
|
||||
/// destination after the real pointer/navigation/command handler ran.
|
||||
Deferred,
|
||||
/// Settle before the UI without allocating or claiming a deferred notice: the ordered latch
|
||||
/// stays closed, text routing is unchanged, and the post-UI finalizer binds the replacement.
|
||||
Pending,
|
||||
/// Settle without a request: no custom editor owns this pass (hidden, stock, no geometry).
|
||||
Unbound,
|
||||
}
|
||||
|
||||
/// Keep already-previewed marked text at its original position, without reinserting it through
|
||||
/// `tool_text`: the caret or document may already have changed. Settle with no request on a route
|
||||
/// that has no custom editor (a stock field, a hidden window); core could attach such a token to
|
||||
/// the stock descriptor.
|
||||
fn interrupt_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
interrupt_incoming_impl(app, ctx, Fence::Unbound);
|
||||
}
|
||||
|
||||
/// Returns whether an active/terminal preview was settled.
|
||||
fn interrupt_incoming_impl(app: &mut VectorcraftApp, ctx: &Context, fence: Fence) -> bool {
|
||||
let viewport = ctx.viewport_id();
|
||||
let previous = app.ime_compositions.get(&viewport).copied();
|
||||
let Some(IncomingComposition::Active(expected) | IncomingComposition::TerminalPending(expected)) = previous else { return false };
|
||||
if fence == Fence::Unbound {
|
||||
// No fence will be bound on this route, so leave no Stale latch that only a marker could
|
||||
// clear. The composition is settled and the adapter returns to Clear.
|
||||
app.ime_compositions.remove(&viewport);
|
||||
} else {
|
||||
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
||||
}
|
||||
app.ime_marked = None;
|
||||
app.ime_discard = true;
|
||||
let view = app.view_info();
|
||||
if app.session.tool_composing() && target(&mut app.session, view) == Some(expected.target) {
|
||||
match app.session.commit_interaction() {
|
||||
Ok(()) => app.session.set_tool_option("commitTyping", &serde_json::Value::Null),
|
||||
Err(error) => app.status(error.to_string()),
|
||||
}
|
||||
}
|
||||
match fence {
|
||||
Fence::Bound => {
|
||||
// Ask for the fence only after settlement: the guard's destination and committed stamp
|
||||
// are the post-commit ones, so an eligible pass does not immediately supersede it.
|
||||
// Refusal (the route became ineligible) retires the local guard and its latch.
|
||||
if !request_guard(app, ctx, false) {
|
||||
app.ime_compositions.remove(&viewport);
|
||||
}
|
||||
}
|
||||
Fence::Deferred => {
|
||||
if let Some(guard) = app.ime_resets.get_mut(&viewport) {
|
||||
guard.request = None;
|
||||
guard.deferred = true;
|
||||
guard.pending = false;
|
||||
}
|
||||
}
|
||||
Fence::Pending => mark_pending(app, ctx),
|
||||
Fence::Unbound => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The finalizer form: check application changes after the canvas/panels ran. A live preview
|
||||
/// ending on its own is an interruption even if the caret happened to land at its old offset. No
|
||||
/// prior composition means a direct commit remains legitimate. Only a post-UI call may bind.
|
||||
pub(crate) fn sync_incoming(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
sync_incoming_impl(app, ctx, Fence::Bound);
|
||||
}
|
||||
|
||||
/// Pre-UI form: the pass's descriptor does not exist yet, so a changed composition or guard is
|
||||
/// settled `pending` and the post-UI finalizer binds the replacement after confirming it.
|
||||
pub(crate) fn sync_incoming_before_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
sync_incoming_impl(app, ctx, Fence::Pending);
|
||||
}
|
||||
|
||||
/// Settlement for a pass that publishes no custom descriptor (standalone hidden logic). It settles
|
||||
/// an interrupted preview without allocating a token that a later stock field could consume.
|
||||
fn sync_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
sync_incoming_impl(app, ctx, Fence::Unbound);
|
||||
}
|
||||
|
||||
fn sync_incoming_impl(app: &mut VectorcraftApp, ctx: &Context, fence: Fence) {
|
||||
let previous = app.ime_compositions.get(&ctx.viewport_id()).copied();
|
||||
let expected = match previous {
|
||||
Some(IncomingComposition::Active(selection) | IncomingComposition::TerminalPending(selection)) => selection,
|
||||
_ => {
|
||||
if !blocked(app, ctx) {
|
||||
return;
|
||||
}
|
||||
let selection = incoming_selection(app);
|
||||
let view = app.view_info();
|
||||
let committed = stamp(&mut app.session, view);
|
||||
if app.ime_resets.get(&ctx.viewport_id()).is_some_and(|guard| {
|
||||
!guard.suspended
|
||||
&& !guard.deferred
|
||||
&& (guard.pending
|
||||
|| guard.destination != selection
|
||||
|| guard.committed != committed
|
||||
|| guard.context != crate::graphics::context_tag(ctx))
|
||||
}) {
|
||||
match fence {
|
||||
Fence::Bound => {
|
||||
request_guard(app, ctx, false);
|
||||
}
|
||||
Fence::Pending => mark_pending(app, ctx),
|
||||
Fence::Deferred | Fence::Unbound => suspend(app, ctx),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let view = app.view_info();
|
||||
if incoming_selection(app) != Some(expected)
|
||||
|| matches!(previous, Some(IncomingComposition::Active(_))) && !app.session.tool_composing()
|
||||
|| !app.session.tool_ime_session_matches(view)
|
||||
{
|
||||
interrupt_incoming_impl(app, ctx, fence);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable identity of the Type tool's current edit target.
|
||||
///
|
||||
/// The owner is the document uid plus the edited text node; the replacement epoch is the Type
|
||||
/// tool's edit session, so leaving and re-entering the same text object starts a fresh IME
|
||||
/// context while ordinary typing, selection and caret moves keep the old one.
|
||||
pub(crate) fn target(session: &mut Session, view: ViewInfo) -> Option<ImeTarget> {
|
||||
session.tool_ime_target(view).map(|(document, node, session)| ImeTarget::new(Id::unique(("vectorcraft-type-ime", document, node)), session))
|
||||
}
|
||||
|
||||
/// The bounded committed text and UTF-8 offsets, or `None` for an explicit unavailability reason.
|
||||
pub(crate) fn committed(session: &mut Session, view: ViewInfo) -> Option<(String, usize, usize)> {
|
||||
match session.tool_ime_surrounding(SURROUNDING_LIMIT, view) {
|
||||
ImeSurrounding::Available { text, cursor, anchor } => Some((text, cursor, anchor)),
|
||||
ImeSurrounding::Unavailable(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A receipt stamp of the current committed context, for a successful mutation's boundary.
|
||||
///
|
||||
/// `None` means there is no edit target at all. An explicit unavailable query (a selection wider
|
||||
/// than the limit, invalid offsets, or a typing session the document changed under) is still a
|
||||
/// stamp: [`note_ime_apply`] refuses to earn a receipt from an unavailable boundary, so the
|
||||
/// mutation stays [`egui::output::ImeChangeCause::Other`].
|
||||
pub(crate) fn stamp(session: &mut Session, view: ViewInfo) -> Option<ImeMutationStamp> {
|
||||
target(session, view)?;
|
||||
let revision = session.active().map_or(0, |document| document.revision);
|
||||
Some(match committed(session, view) {
|
||||
Some((text, cursor, anchor)) => ImeMutationStamp::from_committed(&text, cursor, anchor, None),
|
||||
// Unavailable stamps need a producer revision to order two boundaries: without one,
|
||||
// before and after compare equal and an application edit cannot be distinguished from
|
||||
// an unchanged unavailable snapshot.
|
||||
None => ImeMutationStamp::unavailable_at_revision(revision),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record an accepted IME commit/delete after it succeeded, comparing the committed context
|
||||
/// immediately before and after the engine call.
|
||||
///
|
||||
/// A stale target, a no-op (`before == after`), or an unavailable receipt earns nothing: the
|
||||
/// tracker then reports `Other` for any snapshot change it cannot prove came from the IME.
|
||||
pub(crate) fn note_ime_apply(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &Context,
|
||||
view: ViewInfo,
|
||||
target_before: Option<ImeTarget>,
|
||||
before: Option<ImeMutationStamp>,
|
||||
) {
|
||||
let (Some(target_before), Some(before)) = (target_before, before) else { return };
|
||||
if target(&mut app.session, view) != Some(target_before) {
|
||||
return;
|
||||
}
|
||||
let Some(after) = stamp(&mut app.session, view) else { return };
|
||||
if before == after {
|
||||
return;
|
||||
}
|
||||
// A receipt must describe the committed text on both sides of the mutation. An unavailable
|
||||
// snapshot (an external edit left the typing session stale, or a selection is wider than the
|
||||
// limit) cannot prove what the IME changed, so such a mutation stays `Other`.
|
||||
if before.surrounding_text().available().is_none() || after.surrounding_text().available().is_none() {
|
||||
return;
|
||||
}
|
||||
app.ime_context.note_input_method_apply(ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()), target_before, before, after);
|
||||
}
|
||||
|
||||
/// Record an application-origin text mutation (typing, paste, Backspace/Delete, a menu edit, a
|
||||
/// correction). `Other` then dominates the whole frame, including any IME receipt in it.
|
||||
///
|
||||
/// `before == after` is a no-op and is not reported. A changed target (editing ended) is left to
|
||||
/// the normal context-transition rule.
|
||||
pub(crate) fn note_application_edit(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &Context,
|
||||
view: ViewInfo,
|
||||
target_before: Option<ImeTarget>,
|
||||
before: Option<ImeMutationStamp>,
|
||||
) {
|
||||
let (Some(target_before), Some(before)) = (target_before, before) else { return };
|
||||
if target(&mut app.session, view) != Some(target_before) {
|
||||
return;
|
||||
}
|
||||
let Some(after) = stamp(&mut app.session, view) else { return };
|
||||
if before == after {
|
||||
return;
|
||||
}
|
||||
app.ime_context.note_application_change(ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()), target_before);
|
||||
}
|
||||
|
||||
/// Observe the Type tool once, near the end of this viewport's pass.
|
||||
///
|
||||
/// `focused` is the canvas's own decision (the Type tool wants text, no other egui field owns the
|
||||
/// keyboard, and no dialog or palette covers it). Pass `false` for a hidden/absent owner. The
|
||||
/// tracker gets `cumulative_frame_nr_for` and `cumulative_pass_nr_for` separately, so cause
|
||||
/// accumulates across a frame's discarded passes while an unchanged second pass keeps its cause.
|
||||
pub(crate) fn observe(app: &mut VectorcraftApp, ctx: &Context, focused: bool) -> Option<ImeContextUpdate> {
|
||||
let viewport = ctx.viewport_id();
|
||||
if !focused {
|
||||
return app.ime_context.observe(viewport, None);
|
||||
}
|
||||
let view = app.view_info();
|
||||
let Some(target) = target(&mut app.session, view) else {
|
||||
return app.ime_context.observe(viewport, None);
|
||||
};
|
||||
let committed = committed(&mut app.session, view);
|
||||
let revision = app.session.active().map_or(0, |document| document.revision);
|
||||
let observation = ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target)
|
||||
.focused(true)
|
||||
.purpose(egui::IMEPurpose::Normal)
|
||||
.committed(committed.as_ref().map(|(text, cursor, anchor)| CommittedText::new(text, *cursor, *anchor)))
|
||||
// The helper keeps this only for an unavailable snapshot; available text still compares
|
||||
// its exact bounded contents and selection.
|
||||
.unavailable_revision(revision);
|
||||
app.ime_context.observe(viewport, Some(observation))
|
||||
}
|
||||
|
||||
/// Mark this viewport's IME owner absent for a pass (no document, Home, a hidden canvas).
|
||||
pub(crate) fn observe_none(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
app.ime_context.observe(ctx.viewport_id(), None);
|
||||
}
|
||||
|
||||
/// Observe and write the outgoing descriptor once, at the end of the pass.
|
||||
///
|
||||
/// The canvas records only the caret geometry (`VectorcraftApp::ime_caret_rect`) while it draws.
|
||||
/// By the time this runs, every dialog, floating panel, progress window and the palette has drawn
|
||||
/// and ran its commands, so the owner, focused state, committed text and cause are the ones of the
|
||||
/// latest completed pass. A dialog's own `TextEdit` descriptor is left alone: writing `None` here
|
||||
/// would clobber it.
|
||||
pub(crate) fn end_of_pass(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
let ours = app.ime_caret_rect.is_some() && pass_eligible(app, ctx);
|
||||
if !ours {
|
||||
// No custom editor owns the IME this pass (Home, no document, a dialog, a palette, a hidden
|
||||
// window). Mark the context absent and let marked text stay as typed, as a click away does.
|
||||
// Do not synchronize or allocate here: the stock descriptor must carry no reset token.
|
||||
observe_none(app, ctx);
|
||||
interrupt_incoming_unbound(app, ctx);
|
||||
crate::shortcuts::keep_marked_text(app);
|
||||
return;
|
||||
}
|
||||
sync_incoming(app, ctx);
|
||||
let Some((rect, cursor_rect)) = app.ime_caret_rect else { return };
|
||||
// The tool ended the composition itself: the IME must drop what it still has marked.
|
||||
let mut interrupt = app.ime_marked.is_some() && !app.session.tool_composing();
|
||||
if interrupt {
|
||||
app.ime_marked = None;
|
||||
app.ime_discard = true;
|
||||
// The tool ended a composition that `sync_incoming` did not already settle (for example a
|
||||
// pre-edit without recorded geometry). Publish a request-bound interruption like every
|
||||
// other path, rather than an untokened `should_interrupt_composition`. If the route became
|
||||
// ineligible after `ours` was computed, publish no interruption without its fence.
|
||||
if !request_guard(app, ctx, false) {
|
||||
interrupt = false;
|
||||
}
|
||||
}
|
||||
let update = observe(app, ctx, true);
|
||||
write_output(ctx, update, rect, cursor_rect, interrupt);
|
||||
}
|
||||
|
||||
/// Write every field of the descriptor the shared helper returned. Geometry stays the actual
|
||||
/// caret rectangle; cause and surrounding text come from the tracker, never from a placeholder.
|
||||
pub(crate) fn write_output(ctx: &Context, update: Option<ImeContextUpdate>, rect: egui::Rect, cursor_rect: egui::Rect, interrupt: bool) {
|
||||
let Some(update) = update else {
|
||||
ctx.output_mut(|o| o.ime = None);
|
||||
return;
|
||||
};
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(IMEOutput {
|
||||
owner: update.owner,
|
||||
context_generation: update.context_generation,
|
||||
purpose: update.purpose,
|
||||
rect,
|
||||
cursor_rect,
|
||||
should_interrupt_composition: interrupt,
|
||||
reset_request: None,
|
||||
change_cause: update.change_cause,
|
||||
surrounding_text: update.surrounding_text,
|
||||
});
|
||||
});
|
||||
if let Some(key) = ctx.output(|output| output.ime.as_ref().map(OutputContext::from)) {
|
||||
let id = output_id(ctx.viewport_id());
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|data| data.insert_temp(id, (pass, key)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn same_owner_new_generation_or_purpose_requires_a_replacement_request() {
|
||||
let owner = Id::unique("vectorcraft-ime-output-context-test");
|
||||
let previous = OutputContext { owner, generation: 7, purpose: egui::IMEPurpose::Normal };
|
||||
let guard = ResetGuard {
|
||||
request: None,
|
||||
context: 1,
|
||||
destination: None,
|
||||
committed: None,
|
||||
output: Some(previous),
|
||||
suspended: false,
|
||||
deferred: false,
|
||||
pending: false,
|
||||
};
|
||||
let newer_generation = OutputContext { generation: 8, ..previous };
|
||||
assert_ne!(previous, newer_generation, "the same owner in a new context generation is a new output");
|
||||
assert!(needs_replacement(&guard, None, newer_generation));
|
||||
let other_purpose = OutputContext { purpose: egui::IMEPurpose::Password, ..previous };
|
||||
assert_ne!(previous, other_purpose, "the same owner and generation with another purpose is a new output");
|
||||
assert!(needs_replacement(&guard, None, other_purpose));
|
||||
assert!(!needs_replacement(&guard, None, previous), "an unchanged output keeps its request");
|
||||
}
|
||||
}
|
||||
+734
-103
File diff suppressed because it is too large
Load Diff
@@ -3466,7 +3466,7 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
// its text (#516), the frame after the click that chose them (that click takes the keyboard
|
||||
// from it again).
|
||||
type Field = (egui::Id, egui::text_edit::TextEditState);
|
||||
let (field_key, chosen_key) = (egui::Id::new("menu-bar-field"), egui::Id::new("menu-bar-field-event"));
|
||||
let (field_key, chosen_key) = (egui::Id::unique("menu-bar-field"), egui::Id::unique("menu-bar-field-event"));
|
||||
if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) {
|
||||
ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key));
|
||||
state.store(&ctx, field);
|
||||
@@ -3480,12 +3480,17 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
// No application menu in the window (as in Illustrator): the bar starts at File on every
|
||||
// platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there.
|
||||
let tree: Vec<(&str, Vec<Item>)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect();
|
||||
// A menu bar for its layout and menu-button style, but the titles use `Popup::menu` directly:
|
||||
// egui 0.37's `MenuButton` opens the title under a resting pointer while another menu is open,
|
||||
// and the app switches titles on movement only (`switch_on_hover`, the native behaviour).
|
||||
let (end, open) = egui::MenuBar::new()
|
||||
.ui(ui, |ui| {
|
||||
let mut titles = Vec::with_capacity(tree.len());
|
||||
for (title, items) in tree.iter() {
|
||||
let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text);
|
||||
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
|
||||
let resp = ui.add(egui::Button::new(text));
|
||||
egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked));
|
||||
titles.push(resp);
|
||||
}
|
||||
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
|
||||
})
|
||||
@@ -3590,7 +3595,7 @@ fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool,
|
||||
let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview);
|
||||
let r = match sampled {
|
||||
Some(family) => {
|
||||
let slot = ui.id().with(("font-sample", family));
|
||||
let slot = egui::IdSalt::new(("font-sample", family));
|
||||
let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE));
|
||||
let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner;
|
||||
if let Some(rect) = out.rect(slot) {
|
||||
@@ -4457,10 +4462,10 @@ mod tests {
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::new("covered"), egui::Sense::click());
|
||||
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::unique("covered"), egui::Sense::click());
|
||||
let q = p + egui::vec2(0.0, 100.0);
|
||||
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::new("free"), egui::Sense::click());
|
||||
egui::Area::new(egui::Id::new("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
|
||||
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::unique("free"), egui::Sense::click());
|
||||
egui::Area::new(egui::Id::unique("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
|
||||
ui.allocate_space(egui::vec2(20.0, 20.0));
|
||||
});
|
||||
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
|
||||
@@ -4929,7 +4934,7 @@ mod tests {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let tree = menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let id = egui::Id::new("test-menu");
|
||||
let id = egui::Id::unique("test-menu");
|
||||
for _ in 0..3 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
@@ -4953,7 +4958,7 @@ mod tests {
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let tree = menu_tree();
|
||||
let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap();
|
||||
let id = egui::Id::new("test-long-menu");
|
||||
let id = egui::Id::unique("test-long-menu");
|
||||
let rect = |height: f32| {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height));
|
||||
for _ in 0..3 {
|
||||
|
||||
@@ -567,7 +567,7 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(menu) = app.services.native_menu.as_ref() else { return };
|
||||
let input = ctx
|
||||
.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. })));
|
||||
let state = state_hash(app, input.then_some(app.frame));
|
||||
let state = state_hash(app, input.then_some(app.host_iteration));
|
||||
if menu.state == Some(state) && !menu.dirty {
|
||||
return;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user