mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
M14: distinguish qualification from publication (#4173)
This commit is contained in:
@@ -459,6 +459,7 @@ impl Session {
|
||||
self.with_tool_cx(view, |t, cx| t.ime_session_matches_document(cx))
|
||||
}
|
||||
|
||||
/// Whether the active tool currently owns keyboard text input, as the Type tool does while editing.
|
||||
pub fn tool_wants_text(&self) -> bool {
|
||||
self.tool.wants_text()
|
||||
}
|
||||
|
||||
@@ -798,6 +798,7 @@ pub enum ImeSurrounding {
|
||||
}
|
||||
|
||||
impl Default for ImeSurrounding {
|
||||
/// Report surrounding text as unavailable because no document is active.
|
||||
fn default() -> Self {
|
||||
Self::Unavailable("no document is active")
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
|
||||
/// A mutex (not an `AtomicU64`) keeps this available on wasm targets without 64-bit atomics.
|
||||
static NEXT_EDIT_SESSION: Mutex<u64> = Mutex::new(0);
|
||||
|
||||
/// Return the next process-wide edit-session id. It saturates at `u64::MAX` instead of wrapping to
|
||||
/// zero; the final id can repeat only after 2^64 sessions.
|
||||
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);
|
||||
@@ -170,6 +172,8 @@ impl TypeTool {
|
||||
l
|
||||
}
|
||||
|
||||
/// The edited text used to find word and paragraph boundaries for selection: the typing session's
|
||||
/// runs, or the document's current text when there is no live session or it has gone stale.
|
||||
fn plain(&self, cx: &ToolContext) -> String {
|
||||
let document = self.editing.and_then(|id| Self::text(cx, id));
|
||||
match &self.typing {
|
||||
@@ -321,6 +325,8 @@ impl TypeTool {
|
||||
out
|
||||
}
|
||||
|
||||
/// Begin a new edit session for `id` at `caret`, assigning a fresh session id and clearing typing,
|
||||
/// pre-edit and goal state.
|
||||
fn start_editing(&mut self, id: NodeId, caret: usize) {
|
||||
self.edit_session = next_edit_session();
|
||||
self.editing = Some(id);
|
||||
@@ -1008,6 +1014,8 @@ impl Tool for TypeTool {
|
||||
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)
|
||||
}
|
||||
/// Whether the typing session still describes the edited document; a session ended by an outside
|
||||
/// command reports false until a replacement commit starts a new one.
|
||||
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),
|
||||
@@ -1017,6 +1025,8 @@ impl Tool for TypeTool {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// Handle an editing key: ignore keys while an IME composes, remap arrows in vertical text, step
|
||||
/// type attributes with Alt, and otherwise edit, navigate or end the session.
|
||||
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() {
|
||||
@@ -1183,6 +1193,9 @@ impl Tool for TypeTool {
|
||||
self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) });
|
||||
out
|
||||
}
|
||||
/// Delete the requested bytes or characters before and after the selection or marked text,
|
||||
/// preserving the selection direction and composition and refusing a stale session; byte counts
|
||||
/// round inward to UTF-8 boundaries.
|
||||
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
|
||||
@@ -1235,11 +1248,16 @@ impl Tool for TypeTool {
|
||||
}
|
||||
self.delete_ranges(cx, &ranges)
|
||||
}
|
||||
/// Return the active Type tool's stable IME owner (document uid, target id, replacement epoch),
|
||||
/// or `None` when nothing is being edited.
|
||||
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))
|
||||
}
|
||||
/// Return a bounded logical text window around the caret, anchor and optional pre-edit; a stale
|
||||
/// typing session publishes the document's current text, and unusable or non-boundary offsets
|
||||
/// return `Unavailable` rather than stale data.
|
||||
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");
|
||||
@@ -1279,6 +1297,7 @@ impl Tool for TypeTool {
|
||||
};
|
||||
ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes)
|
||||
}
|
||||
/// Whether an IME pre-edit is currently shown for this Type-tool session.
|
||||
fn composing(&self) -> bool {
|
||||
self.preedit.is_some()
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ pub(crate) fn end_document_drag(ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Temp-memory key for the active canvas drag state.
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-drag")
|
||||
}
|
||||
@@ -3084,10 +3085,12 @@ fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
/// out rather than spilling over its neighbours.
|
||||
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
|
||||
|
||||
/// Temp-memory key recording an artboard-name press before its edit field opens.
|
||||
fn name_press_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-artboard-name-press")
|
||||
}
|
||||
|
||||
/// Temp-memory key for the artboard name field currently being edited.
|
||||
fn name_edit_id() -> egui::Id {
|
||||
egui::Id::unique("canvas-artboard-name-edit")
|
||||
}
|
||||
@@ -4689,6 +4692,8 @@ mod tests {
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no {text:?} text"))
|
||||
}
|
||||
/// Build raw input with a fixed 800 by 600 screen so headless frames keep a stable canvas texture
|
||||
/// size.
|
||||
fn raw() -> egui::RawInput {
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }
|
||||
}
|
||||
@@ -8755,6 +8760,8 @@ mod tests {
|
||||
assert_eq!(plain(&app, id), "雅がくらくか");
|
||||
}
|
||||
|
||||
/// Reset to a blank document, select the Type tool and click document point (100, 100), returning
|
||||
/// the screen position used by IME fixtures.
|
||||
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();
|
||||
|
||||
@@ -336,6 +336,8 @@ fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draw the graph-data sheet: keyboard cell movement, block paste, import, transpose, switch-x/y
|
||||
/// and revert actions, decimals and column width, and only the cells in view.
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let entry_id = egui::Id::unique("graph-data-entry");
|
||||
if ui.memory(|m| m.has_focus(entry_id)) {
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) fn hint(d: &Dialog) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Temp-memory key for the revolve gizmo drag state.
|
||||
fn drag_id() -> Id {
|
||||
Id::unique("revolve-gizmo-drag")
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ struct Cache {
|
||||
shots: Vec<(String, Result<Shot, String>)>,
|
||||
}
|
||||
|
||||
/// Temp-memory key for the Save for Web preview render, original image and optimised shot textures.
|
||||
fn cache_id() -> egui::Id {
|
||||
egui::Id::unique("save-for-web-previews")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::VectorcraftApp;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, VectorcraftApp>;
|
||||
|
||||
/// Build a 300 by 300 document for a real-dialog accessibility harness.
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
@@ -38,6 +39,7 @@ fn strict(app: VectorcraftApp) -> Harness {
|
||||
h
|
||||
}
|
||||
|
||||
/// Export for Screens names its three text fields and exposes no raw widget ids.
|
||||
#[test]
|
||||
fn export_for_screens_names_the_form_text_fields() {
|
||||
let mut app = app();
|
||||
@@ -50,6 +52,8 @@ fn export_for_screens_names_the_form_text_fields() {
|
||||
assert!(h.query_by_role_and_label(Role::TextInput, "prefix").is_none(), "no internal widget id");
|
||||
}
|
||||
|
||||
/// Flatten Transparency names its preset name and three resolution or balance fields and roles the
|
||||
/// balance control as a slider.
|
||||
#[test]
|
||||
fn flatten_transparency_names_its_preset_and_option_fields() {
|
||||
let mut app = app();
|
||||
@@ -66,6 +70,7 @@ fn flatten_transparency_names_its_preset_and_option_fields() {
|
||||
let _ = h.get_by_role_and_label(Role::Slider, "Raster/Vector Balance:");
|
||||
}
|
||||
|
||||
/// Document Setup names the top, bottom, left and right bleed inputs.
|
||||
#[test]
|
||||
fn document_setup_names_each_bleed_side() {
|
||||
let mut app = app();
|
||||
@@ -76,6 +81,8 @@ fn document_setup_names_each_bleed_side() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split Into Grid keeps translated visible labels while naming row and column gutter and total
|
||||
/// inputs by axis and context.
|
||||
#[test]
|
||||
fn split_into_grid_names_row_and_column_fields_with_context() {
|
||||
let mut app = app();
|
||||
@@ -93,6 +100,7 @@ fn split_into_grid_names_row_and_column_fields_with_context() {
|
||||
assert_eq!(h.get_all_by_label("Total:").count(), 2, "the visible total labels are unchanged");
|
||||
}
|
||||
|
||||
/// Artboard Options names the video ruler pixel aspect ratio input.
|
||||
#[test]
|
||||
fn artboard_options_names_the_video_pixel_aspect_ratio_field() {
|
||||
let mut app = app();
|
||||
@@ -103,6 +111,7 @@ fn artboard_options_names_the_video_pixel_aspect_ratio_field() {
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, "Video Ruler Pixel Aspect Ratio:");
|
||||
}
|
||||
|
||||
/// A generic parameter dialog names its number and text inputs, combo boxes and checkbox.
|
||||
#[test]
|
||||
fn generic_parameter_dialog_names_numbers_text_and_choices() {
|
||||
let mut app = app();
|
||||
@@ -130,6 +139,7 @@ fn generic_parameter_dialog_names_numbers_text_and_choices() {
|
||||
let _ = h.get_by_role_and_label(Role::CheckBox, "Flip:");
|
||||
}
|
||||
|
||||
/// Slice Options names its multiline text-display area.
|
||||
#[test]
|
||||
fn slice_options_names_its_text_area() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -56,6 +56,7 @@ impl Harness {
|
||||
self.texts.iter().find(|(s, _)| s == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing text {text:?}"))
|
||||
}
|
||||
|
||||
/// Return the rectangle of the widget drawn under `Id::unique(id)` in the revolve test harness.
|
||||
fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect {
|
||||
self.ctx.read_response(Id::unique(id)).unwrap().rect
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ pub(crate) fn button_id() -> egui::Id {
|
||||
egui::Id::unique("edit-toolbar-button")
|
||||
}
|
||||
|
||||
/// Stable id of the floating edit toolbar.
|
||||
pub(crate) fn toolbar_id() -> egui::Id {
|
||||
egui::Id::unique("edit-toolbar-toolbar")
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ struct OutputContext {
|
||||
}
|
||||
|
||||
impl From<&IMEOutput> for OutputContext {
|
||||
/// Copy the output's owner, context generation and purpose into the comparable publication
|
||||
/// identity.
|
||||
fn from(output: &IMEOutput) -> Self {
|
||||
Self { owner: output.owner, generation: output.context_generation, purpose: output.purpose }
|
||||
}
|
||||
@@ -64,9 +66,12 @@ pub(crate) struct ResetGuard {
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
/// Temp-memory key marking whether this viewport is currently inside a UI pass.
|
||||
fn mode_id(viewport: egui::ViewportId) -> Id {
|
||||
Id::unique(("vectorcraft.ime.in-ui-pass", viewport))
|
||||
}
|
||||
/// Temp-memory key for the pass number and descriptor identity observed during this viewport's UI
|
||||
/// pass.
|
||||
fn output_id(viewport: egui::ViewportId) -> Id {
|
||||
Id::unique(("vectorcraft.ime.pass-output", viewport))
|
||||
}
|
||||
@@ -133,6 +138,9 @@ fn route_live(app: &mut VectorcraftApp, ctx: &Context) -> bool {
|
||||
target(&mut app.session, view).is_some() && app.session.tool_ime_caret(view).is_some()
|
||||
}
|
||||
|
||||
/// Allocate a reset request on an eligible custom-editor route and replace the guard with the
|
||||
/// confirmed destination, commit stamp and prior output identity; suspend instead when the route
|
||||
/// cannot publish.
|
||||
fn request_guard(app: &mut VectorcraftApp, ctx: &Context, deferred: bool) -> bool {
|
||||
if !route_live(app, ctx) {
|
||||
suspend(app, ctx);
|
||||
@@ -163,11 +171,14 @@ fn request_guard(app: &mut VectorcraftApp, ctx: &Context, deferred: bool) -> boo
|
||||
true
|
||||
}
|
||||
|
||||
/// Temp-memory key for the test-only count of IME reset requests since the last take.
|
||||
#[cfg(test)]
|
||||
fn request_count_id() -> Id {
|
||||
Id::unique("vectorcraft.ime.test-request-count")
|
||||
}
|
||||
|
||||
/// Increment the test-only request counter for every reset allocation, including intermediates
|
||||
/// superseded before publication.
|
||||
#[cfg(test)]
|
||||
fn note_test_request(ctx: &Context) {
|
||||
ctx.data_mut(|data| {
|
||||
@@ -202,10 +213,12 @@ pub(crate) fn suspend_if_route_lost(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this viewport has a guard, including a suspended guard that currently owns no request.
|
||||
pub(crate) fn blocked(app: &VectorcraftApp, ctx: &Context) -> bool {
|
||||
app.ime_resets.contains_key(&ctx.viewport_id())
|
||||
}
|
||||
|
||||
/// Return the reset request awaiting native completion for this viewport; test helper.
|
||||
#[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)
|
||||
@@ -306,6 +319,8 @@ fn mark_pending(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
app.ime_compositions.insert(viewport, IncomingComposition::Stale);
|
||||
}
|
||||
|
||||
/// Retire the guard's request and clear its deferred and pending flags while keeping the guard
|
||||
/// blocked until an eligible route allocates a replacement.
|
||||
fn suspend(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) {
|
||||
guard.request = None;
|
||||
@@ -378,6 +393,8 @@ pub(crate) fn controls_without_editor(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the previous pass's caret rectangle and published descriptor identity before this
|
||||
/// viewport's UI runs.
|
||||
pub(crate) fn begin_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
app.ime_caret_rect = None;
|
||||
let id = output_id(ctx.viewport_id());
|
||||
@@ -418,15 +435,21 @@ pub(crate) fn finish_ui(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this pass can publish a custom editor and the descriptor recorded during the pass is the
|
||||
/// one actually published.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Require a custom descriptor, a matching actual output and an eligible route before an IME guard
|
||||
/// may settle or re-arm.
|
||||
fn eligible_with(app: &VectorcraftApp, ctx: &Context, custom: Option<OutputContext>, actual: Option<OutputContext>) -> bool {
|
||||
custom.is_some() && actual == custom && pass_eligible(app, ctx)
|
||||
}
|
||||
|
||||
/// Whether a guard needs a new fence because it was suspended, deferred or pending, its destination
|
||||
/// changed, or its published descriptor identity changed.
|
||||
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)
|
||||
}
|
||||
@@ -443,6 +466,8 @@ pub(crate) fn finish_logic(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current Type-tool target and its anchor and caret offsets, or `None` when no text edit
|
||||
/// target or selection options are available.
|
||||
fn incoming_selection(app: &mut VectorcraftApp) -> Option<IncomingSelection> {
|
||||
let view = app.view_info();
|
||||
let target = target(&mut app.session, view)?;
|
||||
@@ -548,6 +573,9 @@ fn sync_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) {
|
||||
sync_incoming_impl(app, ctx, Fence::Unbound);
|
||||
}
|
||||
|
||||
/// Settle an incoming composition whose selection, composing state, session or context no longer
|
||||
/// matches the recorded expectation, applying the fence policy; with no recorded composition, also
|
||||
/// retire or replace a guard whose destination, commit stamp, context or pending state changed.
|
||||
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 {
|
||||
@@ -774,6 +802,8 @@ pub(crate) fn write_output(ctx: &Context, update: Option<ImeContextUpdate>, rect
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A changed context generation or IME purpose for the same owner requires a replacement reset,
|
||||
/// while an unchanged descriptor keeps the existing request.
|
||||
#[test]
|
||||
fn same_owner_new_generation_or_purpose_requires_a_replacement_request() {
|
||||
let owner = Id::unique("vectorcraft-ime-output-context-test");
|
||||
|
||||
@@ -1961,6 +1961,8 @@ impl VectorcraftApp {
|
||||
|| self.pending_screenshots.iter().any(|p| p.slot.is_none())
|
||||
}
|
||||
|
||||
/// Capture the viewport identity and geometry (content and inner rectangles, pixels per point and
|
||||
/// zoom) whose change advances the screenshot epoch.
|
||||
fn geometry_stamp(ctx: &egui::Context) -> GeometryStamp {
|
||||
GeometryStamp {
|
||||
viewport: ctx.viewport_id(),
|
||||
@@ -2065,6 +2067,8 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify the document, modal dialog and focused widget that the current accepted input batch
|
||||
/// belongs to.
|
||||
fn input_generation(&self) -> InputGeneration {
|
||||
InputGeneration {
|
||||
document: self.session.active().map_or(0, |document| document.uid),
|
||||
|
||||
@@ -1537,6 +1537,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Both stroke rows expose named Weight inputs, and the drop-shadow effect names its Blur input and
|
||||
/// Color radio button.
|
||||
#[test]
|
||||
fn inline_stroke_weight_and_effect_fields_are_named() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
@@ -1211,6 +1211,7 @@ mod tests {
|
||||
assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient", "brushKeyColor": false})));
|
||||
}
|
||||
|
||||
/// The gradient panel names its Angle and Aspect Ratio inputs.
|
||||
#[test]
|
||||
fn angle_and_aspect_fields_are_named() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
@@ -126,10 +126,12 @@ struct ColumnDrag {
|
||||
ids: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Stable id for the layers panel's per-name UI state.
|
||||
fn key(name: &str) -> egui::Id {
|
||||
egui::Id::unique(("layers-panel", name))
|
||||
}
|
||||
|
||||
/// Temp-memory key for the layer rename editor.
|
||||
fn rename_id() -> egui::Id {
|
||||
egui::Id::unique("layers-rename")
|
||||
}
|
||||
|
||||
@@ -464,6 +464,7 @@ pub(crate) fn live_run(app: &mut VectorcraftApp, label: &str, cmd: &str, params:
|
||||
pub(crate) fn pstate<T: Clone + Default + Send + Sync + 'static>(ctx: &egui::Context, key: &str) -> T {
|
||||
ctx.data(|d| d.get_temp::<T>(egui::Id::unique(("panel-state", key)))).unwrap_or_default()
|
||||
}
|
||||
/// Store per-panel UI state in egui temp memory under the `panel-state` namespace.
|
||||
pub(crate) fn set_pstate<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, key: &str, v: T) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("panel-state", key)), v));
|
||||
}
|
||||
|
||||
@@ -971,6 +971,7 @@ mod tests {
|
||||
assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0));
|
||||
}
|
||||
|
||||
/// The Transform panel names its Rotate and Shear inputs.
|
||||
#[test]
|
||||
fn rotate_and_shear_fields_are_named() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
@@ -30,6 +30,7 @@ fn label_id() -> Id {
|
||||
Id::unique("numeric-field-label")
|
||||
}
|
||||
|
||||
/// Temp-memory key for the numeric-field scrub preference.
|
||||
fn enabled_id() -> Id {
|
||||
Id::unique("numeric-field-scrub-enabled")
|
||||
}
|
||||
|
||||
@@ -514,6 +514,8 @@ fn apply_type_event(
|
||||
}
|
||||
}
|
||||
|
||||
/// Process one frame's events against the keymap at most once per context, viewport and frame, so
|
||||
/// reruns cannot fire a shortcut or tool key twice.
|
||||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// One UI pass takes a frame's events. A discarded rerun and a test helper that also calls
|
||||
// `logic` see the same events again; they must not fire a shortcut or tool key twice. The
|
||||
|
||||
@@ -46,6 +46,7 @@ pub(crate) struct Publishing {
|
||||
}
|
||||
|
||||
impl Publishing {
|
||||
/// Whether the latest Copy or Cut still has formats waiting to be written to the system clipboard.
|
||||
pub(crate) fn is_pending(&self) -> bool {
|
||||
self.pending
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use vectorcraft_engine::Session;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Build a new 400 by 300 document with one selected rectangle for lifecycle fixtures.
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
@@ -16,10 +17,12 @@ fn app() -> VectorcraftApp {
|
||||
app
|
||||
}
|
||||
|
||||
/// Count child objects across every layer of the active document.
|
||||
fn objects(app: &VectorcraftApp) -> usize {
|
||||
app.session.active().map_or(0, |st| st.doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum())
|
||||
}
|
||||
|
||||
/// Return the number of undo entries in the active document's history.
|
||||
fn undo(app: &VectorcraftApp) -> usize {
|
||||
app.session.active().map_or(0, |st| st.history.undo.len())
|
||||
}
|
||||
@@ -34,6 +37,8 @@ fn screen() -> egui::Rect {
|
||||
/// (which only pastes when the app copied text itself).
|
||||
const PASTE_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#;
|
||||
|
||||
/// A discarded rerun must execute a duplicate paste exactly once while the host iteration still
|
||||
/// ticks once.
|
||||
#[test]
|
||||
fn a_discarded_rerun_ticks_once_and_runs_a_duplicate_once() {
|
||||
let mut app = app();
|
||||
@@ -75,6 +80,8 @@ fn a_discarded_rerun_ticks_once_and_runs_a_duplicate_once() {
|
||||
assert_eq!(undo(&app), before_undo + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// Logic-only iterations advance the host clock but never read the stale raw events a hidden window
|
||||
/// replays.
|
||||
#[test]
|
||||
fn logic_only_iterations_tick_without_replaying_input() {
|
||||
let mut app = app();
|
||||
@@ -103,6 +110,8 @@ fn logic_only_iterations_tick_without_replaying_input() {
|
||||
assert_eq!(undo(&app), before_undo, "no command ran hidden");
|
||||
}
|
||||
|
||||
/// A visible frame consumes a paste once; later logic-only iterations with the same raw input must
|
||||
/// not replay it.
|
||||
#[test]
|
||||
fn a_consumed_event_is_not_replayed_by_a_later_logic_iteration() {
|
||||
let mut app = app();
|
||||
@@ -157,6 +166,8 @@ fn ime_frame(
|
||||
out.platform_output.ime
|
||||
}
|
||||
|
||||
/// Create a blank 400 by 300 document with one text object and return the app and node id;
|
||||
/// placeholder text is disabled so the object is the only text.
|
||||
fn app_with_text(text: &str) -> (VectorcraftApp, u64) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.prefs.placeholder_text = false;
|
||||
@@ -165,6 +176,7 @@ fn app_with_text(text: &str) -> (VectorcraftApp, u64) {
|
||||
(app, id)
|
||||
}
|
||||
|
||||
/// Read the plain text of the given text node in these lifecycle fixtures.
|
||||
fn plain(app: &VectorcraftApp, id: u64) -> String {
|
||||
match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
|
||||
@@ -172,6 +184,8 @@ fn plain(app: &VectorcraftApp, id: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a pointer move and a primary press/release pair through the production tick then UI
|
||||
/// lifecycle.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) {
|
||||
let button =
|
||||
|pos: egui::Pos2, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
@@ -191,6 +205,7 @@ fn begin_typing(app: &mut VectorcraftApp, ctx: &egui::Context, id: u64) -> egui:
|
||||
at
|
||||
}
|
||||
|
||||
/// Build an IME pre-edit event whose active clause is an empty character range at `chars`.
|
||||
fn preedit(t: &str, chars: usize) -> Event {
|
||||
Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) })
|
||||
}
|
||||
@@ -210,6 +225,7 @@ fn interruption_editor() -> (VectorcraftApp, egui::Context, u64, egui::Pos2) {
|
||||
(app, ctx, id, point)
|
||||
}
|
||||
|
||||
/// Build a one-character or one-byte IME delete-surrounding event according to `bytes`.
|
||||
fn surrounding_delete(bytes: bool) -> Event {
|
||||
Event::Ime(if bytes {
|
||||
egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }
|
||||
@@ -218,10 +234,13 @@ fn surrounding_delete(bytes: bool) -> Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the reset-completed event for the app's current pending IME request.
|
||||
fn reset_marker(app: &VectorcraftApp, ctx: &egui::Context) -> Event {
|
||||
Event::ImeResetCompleted { request_id: crate::ime::pending_request(app, ctx).expect("a real interruption requested a fence") }
|
||||
}
|
||||
|
||||
/// Click the given point, assert the resulting custom descriptor publishes the real reset request,
|
||||
/// and return that request.
|
||||
fn pointer_reset(app: &mut VectorcraftApp, ctx: &egui::Context, point: egui::Pos2) -> egui::ImeResetId {
|
||||
let press = Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() };
|
||||
let output = ime_frame(app, ctx, vec![Event::PointerMoved(point), press], |_| {}).expect("the destination still edits text");
|
||||
@@ -232,10 +251,13 @@ fn pointer_reset(app: &mut VectorcraftApp, ctx: &egui::Context, point: egui::Pos
|
||||
request
|
||||
}
|
||||
|
||||
/// Build a pressed, non-repeat key event with the given key and modifiers.
|
||||
fn pressed(key: egui::Key, modifiers: egui::Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
/// A replacement fence admits an immediate fresh suffix without an old terminal, for empty and non-
|
||||
/// empty pre-edits and for same- and next-batch ordering.
|
||||
#[test]
|
||||
fn reset_fence_without_an_old_terminal_admits_the_immediate_fresh_suffix() {
|
||||
for terminal_pending in [false, true] {
|
||||
@@ -261,6 +283,8 @@ fn reset_fence_without_an_old_terminal_admits_the_immediate_fresh_suffix() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the newest reset request for this viewport, context and destination completes; old, foreign
|
||||
/// and duplicate markers cannot retire it or admit input.
|
||||
#[test]
|
||||
fn newest_full_reset_id_rejects_old_foreign_and_duplicate_controls() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -308,6 +332,8 @@ fn newest_full_reset_id_rejects_old_foreign_and_duplicate_controls() {
|
||||
assert_eq!(plain(&app, id), "abQRcdé", "a duplicate marker cannot retire the new live preview");
|
||||
}
|
||||
|
||||
/// A pressed navigation key retires the old fence, moves the caret once, and the final destination
|
||||
/// publishes a replacement fence before its suffix is accepted.
|
||||
#[test]
|
||||
fn pressed_navigation_retires_the_old_fence_until_the_real_destination_is_known() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -334,6 +360,8 @@ fn pressed_navigation_retires_the_old_fence_until_the_real_destination_is_known(
|
||||
assert_eq!(plain(&app, id), "abがcdé");
|
||||
}
|
||||
|
||||
/// A real permitted command key still runs while composing or after retirement; the cached
|
||||
/// composing flag must not swallow Cmd+R.
|
||||
#[test]
|
||||
fn a_pressed_command_runs_after_composition_retirement_and_after_its_fence() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -353,6 +381,8 @@ fn a_pressed_command_runs_after_composition_retirement_and_after_its_fence() {
|
||||
assert_eq!(app.ui.view.rulers, !rulers);
|
||||
}
|
||||
|
||||
/// A request allocated in a discarded pass is retired; an unavailable final pass leaves no token,
|
||||
/// and the same caret re-arms once when visible again.
|
||||
#[test]
|
||||
fn discarded_request_then_hidden_output_rearms_the_same_caret_once() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -397,6 +427,8 @@ fn discarded_request_then_hidden_output_rearms_the_same_caret_once() {
|
||||
assert_eq!(plain(&app, id), "abがcdé");
|
||||
}
|
||||
|
||||
/// Stock text remains editable while custom fences retire; returning from stock focus and losing
|
||||
/// window focus each allocate a fresh fence.
|
||||
#[test]
|
||||
fn stock_field_and_focus_loss_retire_custom_fences_without_consuming_stock_text() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -444,6 +476,8 @@ fn stock_field_and_focus_loss_retire_custom_fences_without_consuming_stock_text(
|
||||
assert_eq!(plain(&app, id), "aがbcdé");
|
||||
}
|
||||
|
||||
/// Text, paste and cut events before a deferred pointer's real handler are dropped; the pointer
|
||||
/// still moves the caret and publishes a replacement fence.
|
||||
#[test]
|
||||
fn a_deferred_pointer_drops_text_and_clipboard_until_its_real_handler_and_fence() {
|
||||
for paste in [false, true] {
|
||||
@@ -476,6 +510,8 @@ fn a_deferred_pointer_drops_text_and_clipboard_until_its_real_handler_and_fence(
|
||||
}
|
||||
}
|
||||
|
||||
/// A hidden route allocates no reset; an old marker and pointer suffix are inert and cannot lend a
|
||||
/// token to a later stock field.
|
||||
#[test]
|
||||
fn hidden_pointer_and_marker_leave_no_token_for_a_later_stock_field() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -516,6 +552,8 @@ fn hidden_pointer_and_marker_leave_no_token_for_a_later_stock_field() {
|
||||
assert!(crate::ime::pending_request(&app, &ctx).is_none());
|
||||
}
|
||||
|
||||
/// A hidden logic pass settles a changed composition without allocating, leaving no token for a
|
||||
/// later stock field.
|
||||
#[test]
|
||||
fn hidden_logic_with_a_changed_composition_leaves_no_token_for_a_later_stock_field() {
|
||||
let (mut app, ctx, id, _) = interruption_editor();
|
||||
@@ -542,6 +580,8 @@ fn hidden_logic_with_a_changed_composition_leaves_no_token_for_a_later_stock_fie
|
||||
assert!(stock.reset_request.is_none(), "hidden logic leaves no token for the stock field");
|
||||
}
|
||||
|
||||
/// Home suspends before input, so an interrupted commit allocates no reset and its old marker
|
||||
/// cannot rearm or leak into a stock field.
|
||||
#[test]
|
||||
fn home_with_an_interrupted_commit_allocates_no_reset_and_leaves_no_stock_token() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -573,6 +613,8 @@ fn home_with_an_interrupted_commit_allocates_no_reset_and_leaves_no_stock_token(
|
||||
assert!(crate::ime::pending_request(&app, &ctx).is_none(), "the old marker cannot rearm a retired fence");
|
||||
}
|
||||
|
||||
/// A command batch that opens the palette allocates no speculative reset; the palette's stock field
|
||||
/// receives no custom token.
|
||||
#[test]
|
||||
fn command_palette_opened_by_a_commit_batch_gets_no_speculative_reset() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -598,6 +640,8 @@ fn command_palette_opened_by_a_commit_batch_gets_no_speculative_reset() {
|
||||
assert_eq!(plain(&app, id), "axbcdé", "the application text lands at the fenced caret");
|
||||
}
|
||||
|
||||
/// Presses that end on an invalid destination release no custom descriptor and allocate no reset
|
||||
/// for the stock field.
|
||||
#[test]
|
||||
fn multiple_pointer_presses_ending_on_an_invalid_destination_leave_no_stock_token() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -627,6 +671,8 @@ fn multiple_pointer_presses_ending_on_an_invalid_destination_leave_no_stock_toke
|
||||
assert!(stock.reset_request.is_none(), "the invalidated final pointer leaves no stock token");
|
||||
}
|
||||
|
||||
/// Cmd+X and Cmd+V in a deferred command batch are dropped until the replacement fence, leaving the
|
||||
/// text and undo history unchanged.
|
||||
#[test]
|
||||
fn deferred_command_key_forms_drop_cmd_x_and_cmd_v_until_the_fence() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -655,6 +701,8 @@ fn deferred_command_key_forms_drop_cmd_x_and_cmd_v_until_the_fence() {
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
/// A live composition alone requests no reset; a stock field publishes no custom token, and
|
||||
/// returning to Type accepts fresh composition.
|
||||
#[test]
|
||||
fn a_stock_field_with_an_active_composition_but_no_guard_gets_no_reset_request() {
|
||||
let (mut app, ctx, id, _) = interruption_editor();
|
||||
@@ -687,6 +735,8 @@ fn a_stock_field_with_an_active_composition_but_no_guard_gets_no_reset_request()
|
||||
assert_eq!(plain(&app, id), "aがbcdé", "a clear adapter accepts fresh composition without a stale latch");
|
||||
}
|
||||
|
||||
/// Opening a modal after the canvas suspends the fence; returning to the same caret allocates a new
|
||||
/// fence and old input stays stale.
|
||||
#[test]
|
||||
fn a_modal_dialog_suspends_a_live_fence_and_the_same_caret_rearms() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -711,6 +761,8 @@ fn a_modal_dialog_suspends_a_live_fence_and_the_same_caret_rearms() {
|
||||
assert_eq!(plain(&app, id), "aがbcdé");
|
||||
}
|
||||
|
||||
/// An absent pass starts a new context generation for the same owner and publishes a replacement
|
||||
/// fence on return.
|
||||
#[test]
|
||||
fn a_same_owner_absent_pass_gets_a_new_generation_and_replacement_fence() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -731,6 +783,8 @@ fn a_same_owner_absent_pass_gets_a_new_generation_and_replacement_fence() {
|
||||
assert_eq!(plain(&app, id), "aがbcdé");
|
||||
}
|
||||
|
||||
/// An occluded pass leaves the marker inert and keeps the guard suspended until a visible pass re-
|
||||
/// arms it.
|
||||
#[test]
|
||||
fn geometry_loss_before_a_marker_keeps_the_fence_suspended_until_reactivation() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -757,6 +811,8 @@ fn geometry_loss_before_a_marker_keeps_the_fence_suspended_until_reactivation()
|
||||
assert_eq!(plain(&app, id), "aがbcdé");
|
||||
}
|
||||
|
||||
/// Settling a preview through an application selection change allocates exactly one fence for the
|
||||
/// post-settlement destination, with no preflight retry.
|
||||
#[test]
|
||||
fn settling_a_preview_without_a_pointer_allocates_one_request_after_the_commit() {
|
||||
let (mut app, ctx, id, _) = interruption_editor();
|
||||
@@ -778,6 +834,8 @@ fn settling_a_preview_without_a_pointer_allocates_one_request_after_the_commit()
|
||||
assert_eq!(plain(&app, id), "aがbcdé");
|
||||
}
|
||||
|
||||
/// Marked text left without a recorded composition triggers an interrupting descriptor whose
|
||||
/// request-bound fence admits only fresh input.
|
||||
#[test]
|
||||
fn an_untokened_marked_text_interruption_binds_a_request() {
|
||||
let (mut app, ctx, id, _) = interruption_editor();
|
||||
@@ -793,6 +851,8 @@ fn an_untokened_marked_text_interruption_binds_a_request() {
|
||||
assert_eq!(plain(&app, id), "abcdが");
|
||||
}
|
||||
|
||||
/// Logic inside a full UI pass preserves the fence; repeated standalone logic keeps it blocked and
|
||||
/// the first UI return re-arms once.
|
||||
#[test]
|
||||
fn full_pass_logic_keeps_its_fence_but_standalone_logic_rearms_once() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -834,6 +894,8 @@ fn full_pass_logic_keeps_its_fence_but_standalone_logic_rearms_once() {
|
||||
assert_eq!(plain(&app, id), "aがbcdé");
|
||||
}
|
||||
|
||||
/// An application edit that preserves the caret but changes committed text invalidates the fence
|
||||
/// before its raw suffix runs.
|
||||
#[test]
|
||||
fn same_caret_external_edit_invalidates_a_fence_before_its_raw_suffix() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -858,6 +920,8 @@ fn same_caret_external_edit_invalidates_a_fence_before_its_raw_suffix() {
|
||||
assert_eq!(plain(&app, id), "Aがbcdé");
|
||||
}
|
||||
|
||||
/// An auxiliary viewport observes only its own guard; a replacement egui context rejects the old
|
||||
/// request and publishes a new one.
|
||||
#[test]
|
||||
fn a_replaced_context_and_auxiliary_ui_cannot_reuse_the_root_fence() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -895,6 +959,8 @@ fn a_replaced_context_and_auxiliary_ui_cannot_reuse_the_root_fence() {
|
||||
assert_eq!(plain(&app, id), "aがbcdé");
|
||||
}
|
||||
|
||||
/// A hidden logic pass neither interrupts an intact composition nor retries acknowledgement; non-
|
||||
/// empty and old-terminal input stays rejected.
|
||||
#[test]
|
||||
fn hidden_logic_preserves_an_uninterrupted_composition_and_no_ack_stays_closed() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -917,6 +983,8 @@ fn hidden_logic_preserves_an_uninterrupted_composition_and_no_ack_stays_closed()
|
||||
}
|
||||
}
|
||||
|
||||
/// A real pointer interruption rejects following-batch IME deletes and commits without previewing
|
||||
/// them, then admits fresh input only after its fence.
|
||||
#[test]
|
||||
fn pointer_interruption_rejects_next_batch_ime_mutations_and_allows_fresh_input() {
|
||||
for bytes in [false, true] {
|
||||
@@ -949,6 +1017,8 @@ fn pointer_interruption_rejects_next_batch_ime_mutations_and_allows_fresh_input(
|
||||
}
|
||||
}
|
||||
|
||||
/// Late composition input after clicking another text object cannot mutate either object; the
|
||||
/// replacement target's fence admits only its own commit.
|
||||
#[test]
|
||||
fn target_switch_rejects_late_composition_input_before_mutating_the_new_text() {
|
||||
for bytes in [false, true] {
|
||||
@@ -986,6 +1056,8 @@ fn target_switch_rejects_late_composition_input_before_mutating_the_new_text() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A composition's own delete plus commit, and a non-composing pointer commit, remain accepted
|
||||
/// single undoable edits.
|
||||
#[test]
|
||||
fn valid_composition_delete_commit_and_noncomposing_pointer_commit_stay_accepted() {
|
||||
for bytes in [false, true] {
|
||||
@@ -1010,6 +1082,8 @@ fn valid_composition_delete_commit_and_noncomposing_pointer_commit_stay_accepted
|
||||
assert_eq!(plain(&app, id), "abcd");
|
||||
}
|
||||
|
||||
/// A stale non-empty pre-edit stays rejected across idle frames until its matching fence; only
|
||||
/// post-fence input changes text.
|
||||
#[test]
|
||||
fn a_stale_nonempty_preedit_stays_rejected_until_its_matching_fence() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -1034,6 +1108,8 @@ fn a_stale_nonempty_preedit_stays_rejected_until_its_matching_fence() {
|
||||
assert_eq!(plain(&app, id), text);
|
||||
}
|
||||
|
||||
/// An application caret change settles the preview at its old position and rejects old input
|
||||
/// without changing the document.
|
||||
#[test]
|
||||
fn an_external_caret_change_keeps_preview_at_its_old_position_and_rejects_old_input() {
|
||||
let (mut app, ctx, id, _) = interruption_editor();
|
||||
@@ -1051,6 +1127,8 @@ fn an_external_caret_change_keeps_preview_at_its_old_position_and_rejects_old_in
|
||||
assert_eq!(undo(&app), steps);
|
||||
}
|
||||
|
||||
/// An application edit while typing makes the payload and following commit `Other` and publishes
|
||||
/// the document's current text, not the stale session.
|
||||
#[test]
|
||||
fn external_edit_while_typing_is_other_and_ime_commit_uses_the_document_text() {
|
||||
let (mut app, id) = app_with_text("Same");
|
||||
@@ -1083,6 +1161,7 @@ fn external_edit_while_typing_is_other_and_ime_commit_uses_the_document_text() {
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("XYZy", 4, 4));
|
||||
}
|
||||
|
||||
/// A dialog opened after the canvas takes IME ownership away from the Type tool on that pass.
|
||||
#[test]
|
||||
fn a_dialog_opened_after_the_canvas_takes_the_ime_away_from_the_type_tool() {
|
||||
let (mut app, id) = app_with_text("Same");
|
||||
@@ -1103,6 +1182,8 @@ fn a_dialog_opened_after_the_canvas_takes_the_ime_away_from_the_type_tool() {
|
||||
assert!(during.is_none(), "the canvas must not own the IME while a dialog opened after it: {during:?}");
|
||||
}
|
||||
|
||||
/// Home removes the Type tool owner, clears marked text and asks the system IME to discard its
|
||||
/// composition.
|
||||
#[test]
|
||||
fn home_takes_down_a_live_composition() {
|
||||
let (mut app, id) = app_with_text("Same");
|
||||
@@ -1121,6 +1202,8 @@ fn home_takes_down_a_live_composition() {
|
||||
assert!(!app.session.tool_composing(), "no stale composition survives Home");
|
||||
}
|
||||
|
||||
/// Closing the active document removes the IME owner, clears marked text and asks the system IME to
|
||||
/// discard its composition.
|
||||
#[test]
|
||||
fn closing_the_document_takes_down_a_live_composition() {
|
||||
let (mut app, id) = app_with_text("Same");
|
||||
@@ -1137,6 +1220,8 @@ fn closing_the_document_takes_down_a_live_composition() {
|
||||
assert!(app.take_ime_discard(), "the system IME is told to drop its marked text");
|
||||
}
|
||||
|
||||
/// A commit whose text the document rewrites through smart quotes or control filtering is reported
|
||||
/// as `Other`, not `InputMethod`.
|
||||
#[test]
|
||||
fn an_ime_commit_the_document_rewrites_is_other() {
|
||||
let (mut app, id) = app_with_text("Same");
|
||||
@@ -1158,6 +1243,8 @@ fn an_ime_commit_the_document_rewrites_is_other() {
|
||||
assert_eq!(filtered.change_cause, egui::output::ImeChangeCause::Other, "filtering is not an IME insertion");
|
||||
}
|
||||
|
||||
/// Smart-quote replacement examines the character before the pre-edit, so the rewritten commit is
|
||||
/// reported as `Other`.
|
||||
#[test]
|
||||
fn an_ime_quote_commit_uses_the_character_before_its_pre_edit() {
|
||||
let (mut app, id) = app_with_text("a");
|
||||
@@ -1215,6 +1302,8 @@ fn ime_delete_surrounding_after_end_typing_keeps_the_next_commit_input_method()
|
||||
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod, "a caret move is not an application-side reset");
|
||||
}
|
||||
|
||||
/// Reloading a document keeps its uid and text-node id but changes the edit-session replacement
|
||||
/// epoch.
|
||||
#[test]
|
||||
fn a_reloaded_document_gets_a_new_ime_replacement_epoch() {
|
||||
let (mut app, id) = app_with_text("Same");
|
||||
@@ -1372,10 +1461,12 @@ fn an_unavailable_ime_boundary_carries_the_document_revision() {
|
||||
assert!(after.unavailable_revision().is_some_and(|after| after > revision), "the document revision advanced");
|
||||
}
|
||||
|
||||
/// Build a primary-pointer button event at `at` with the requested press state.
|
||||
fn primary_button(at: egui::Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// Build a left-arrow key event marked as a physical key press or release.
|
||||
fn left_key(pressed: bool) -> Event {
|
||||
Event::Key { key: egui::Key::ArrowLeft, physical_key: Some(egui::Key::ArrowLeft), pressed, repeat: false, modifiers: Default::default() }
|
||||
}
|
||||
@@ -1432,6 +1523,8 @@ fn a_pointer_before_same_batch_terminal_input_preserves_the_interrupted_text() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Application navigation before a same-batch pending terminal is applied once; the old
|
||||
/// delete/commit suffix is rejected and only input after the fence is admitted.
|
||||
#[test]
|
||||
fn application_navigation_before_same_batch_pending_terminal_rejects_its_mutations() {
|
||||
for bytes in [false, true] {
|
||||
@@ -1485,6 +1578,8 @@ fn terminal_before_a_same_batch_pointer_is_accepted_in_both_composition_states()
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal followed by application navigation keeps the valid commit and runs the navigation; an
|
||||
/// active IME's own navigation does not interrupt it.
|
||||
#[test]
|
||||
fn terminal_before_navigation_is_accepted_and_active_ime_navigation_stays_owned() {
|
||||
for bytes in [false, true] {
|
||||
@@ -1505,6 +1600,8 @@ fn terminal_before_navigation_is_accepted_and_active_ime_navigation_stays_owned(
|
||||
}
|
||||
}
|
||||
|
||||
/// A pointer marker without an observed old composition does not reject a direct commit; terminal
|
||||
/// text still executes before pointer placement.
|
||||
#[test]
|
||||
fn a_pointer_marker_without_an_old_composition_does_not_reject_a_direct_commit() {
|
||||
let (mut app, ctx, id, point) = interruption_editor();
|
||||
@@ -1517,6 +1614,8 @@ fn a_pointer_marker_without_an_old_composition_does_not_reject_a_direct_commit()
|
||||
assert_eq!(plain(&app, id), "abcd");
|
||||
}
|
||||
|
||||
/// A move within an already-held gesture before a same-batch terminal interrupts the old
|
||||
/// composition; the retained move reaches the tool and only the live preview commits.
|
||||
#[test]
|
||||
fn a_held_pointer_move_before_same_batch_terminal_also_interrupts_the_old_composition() {
|
||||
for bytes in [false, true] {
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::{Services, VectorcraftApp};
|
||||
|
||||
const SCREEN: egui::Vec2 = vec2(320.0, 200.0);
|
||||
|
||||
/// Build a screenshot test app with a write service, control channel and temporary directory on a
|
||||
/// 100 by 100 document.
|
||||
fn app(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, std::path::PathBuf) {
|
||||
let dir = vectorcraft_testkit::temp_dir(tag);
|
||||
let services =
|
||||
@@ -24,12 +26,14 @@ fn app(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender<ControlRequest>, s
|
||||
(app, tx, dir)
|
||||
}
|
||||
|
||||
/// Submit a `ui.screenshot` control request and return its reply receiver.
|
||||
fn request(tx: &std::sync::mpsc::Sender<ControlRequest>, params: serde_json::Value) -> std::sync::mpsc::Receiver<serde_json::Value> {
|
||||
let (req, reply) = ControlRequest::new("ui.screenshot", params);
|
||||
tx.send(req).unwrap();
|
||||
reply
|
||||
}
|
||||
|
||||
/// Run one tick-then-UI host frame at the requested screen size and discard texture deltas.
|
||||
fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, size: egui::Vec2, events: Vec<Event>) -> egui::FullOutput {
|
||||
let raw = egui::RawInput { events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), ..Default::default() };
|
||||
app.begin_host_iteration();
|
||||
@@ -41,6 +45,7 @@ fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, size: egui::Vec2, eve
|
||||
out
|
||||
}
|
||||
|
||||
/// Run one host frame at the standard 320 by 200 screenshot size.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::FullOutput {
|
||||
frame_at(app, ctx, SCREEN, vec![])
|
||||
}
|
||||
@@ -85,6 +90,8 @@ fn admit(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
allow_due(app);
|
||||
}
|
||||
|
||||
/// One admitted capture completes once, writes the named file, replies with its size and releases
|
||||
/// the reservation.
|
||||
#[test]
|
||||
fn a_request_saves_one_file_and_replies_once() {
|
||||
let (mut app, tx, dir) = app("screenshot-one");
|
||||
@@ -108,6 +115,7 @@ fn a_request_saves_one_file_and_replies_once() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A timed-out request replies once and writes nothing; a later completion is inert.
|
||||
#[test]
|
||||
fn a_timed_out_request_writes_nothing_and_a_late_completion_is_inert() {
|
||||
let (mut app, tx, dir) = app("screenshot-timeout");
|
||||
@@ -133,6 +141,8 @@ fn a_timed_out_request_writes_nothing_and_a_late_completion_is_inert() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A completion collected after the deadline is neither encoded nor written and replies once with
|
||||
/// the no-frame error.
|
||||
#[test]
|
||||
fn a_completion_that_arrives_after_the_deadline_is_not_saved() {
|
||||
let (mut app, tx, dir) = app("screenshot-deadline");
|
||||
@@ -157,6 +167,8 @@ fn a_completion_that_arrives_after_the_deadline_is_not_saved() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Admission refuses a fifth request without evicting the four pending ones and releases capacity
|
||||
/// when one completes.
|
||||
#[test]
|
||||
fn admission_rejects_more_than_four_requests_without_evicting_one_and_releases_a_finished_one() {
|
||||
let (mut app, tx, dir) = app("screenshot-admission");
|
||||
@@ -188,6 +200,8 @@ fn admission_rejects_more_than_four_requests_without_evicting_one_and_releases_a
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Pixels with a different size are stale, produce no reply or file, and a retry with the matching
|
||||
/// image completes the request.
|
||||
#[test]
|
||||
fn an_image_of_another_size_is_stale_and_retried() {
|
||||
let (mut app, tx, dir) = app("screenshot-size");
|
||||
@@ -213,6 +227,8 @@ fn an_image_of_another_size_is_stale_and_retried() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Changing and restoring the DPI still invalidates in-flight pixels by epoch; the retry for the
|
||||
/// current geometry succeeds.
|
||||
#[test]
|
||||
fn a_geometry_change_and_change_back_still_rejects_the_old_pixels() {
|
||||
let (mut app, tx, dir) = app("screenshot-epoch");
|
||||
@@ -243,6 +259,8 @@ fn a_geometry_change_and_change_back_still_rejects_the_old_pixels() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Switching documents while a capture is in flight fails the request instead of saving new-
|
||||
/// document pixels.
|
||||
#[test]
|
||||
fn a_document_change_while_a_capture_is_in_flight_fails_instead_of_saving_it() {
|
||||
let (mut app, tx, dir) = app("screenshot-document");
|
||||
@@ -266,6 +284,8 @@ fn a_document_change_while_a_capture_is_in_flight_fails_instead_of_saving_it() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Switching documents during settle fails at issuance without ever issuing a capture or keeping a
|
||||
/// reservation.
|
||||
#[test]
|
||||
fn a_document_change_during_the_settle_fails_at_issuance_without_issuing_a_capture() {
|
||||
let (mut app, tx, dir) = app("screenshot-document-settle");
|
||||
@@ -292,6 +312,7 @@ fn a_document_change_during_the_settle_fails_at_issuance_without_issuing_a_captu
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A second stale-sized completion answers the request once with an error and writes nothing.
|
||||
#[test]
|
||||
fn a_second_stale_completion_replies_once_with_an_error_and_writes_nothing() {
|
||||
let (mut app, tx, dir) = app("screenshot-stale-twice");
|
||||
@@ -315,6 +336,8 @@ fn a_second_stale_completion_replies_once_with_an_error_and_writes_nothing() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A hidden window's logic pass requests a hidden paint instead of issuing the queued capture; the
|
||||
/// capture remains pending.
|
||||
#[test]
|
||||
fn a_hidden_window_is_asked_to_paint_the_queued_capture() {
|
||||
let (mut app, tx, dir) = app("screenshot-hidden");
|
||||
@@ -336,6 +359,7 @@ fn a_hidden_window_is_asked_to_paint_the_queued_capture() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// An oversized claimed image is refused from the reply-encoding budget before any copy or encode.
|
||||
#[test]
|
||||
fn a_data_reply_over_the_encoding_budget_is_refused_before_encoding() {
|
||||
let (mut app, _tx, _dir) = app("screenshot-encoding");
|
||||
@@ -347,6 +371,8 @@ fn a_data_reply_over_the_encoding_budget_is_refused_before_encoding() {
|
||||
assert!(answer["error"].as_str().unwrap_or_default().contains("reply-encoding limit"), "{answer}");
|
||||
}
|
||||
|
||||
/// A path plus data capture writes the file and omits an oversized inline PNG; without a path the
|
||||
/// same request fails.
|
||||
#[test]
|
||||
fn a_path_plus_data_capture_writes_the_file_and_omits_an_oversized_inline_png() {
|
||||
let (mut app, _tx, dir) = app("screenshot-path-data");
|
||||
|
||||
@@ -16,12 +16,15 @@ use crate::{icons, scrub};
|
||||
struct HashOnly<T>(T);
|
||||
|
||||
impl<T: std::hash::Hash> std::hash::Hash for HashOnly<T> {
|
||||
/// Hash only the wrapped id salt, deliberately omitting the debug-only identity that egui 0.37's
|
||||
/// `AsIdSalt` requires.
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.0.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for HashOnly<T> {
|
||||
/// Format any `HashOnly` salt as the fixed text `widget id` so ids do not expose their value.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("widget id")
|
||||
}
|
||||
@@ -246,6 +249,8 @@ struct NextLabel {
|
||||
next_auto_id: egui::Id,
|
||||
}
|
||||
|
||||
/// Record a label's widget id, viewport, pass, position and next auto id so only the immediately
|
||||
/// following field can take the label.
|
||||
fn remember_next_label(ui: &Ui, id: egui::Id) {
|
||||
let label = NextLabel {
|
||||
id,
|
||||
@@ -3579,6 +3584,7 @@ pub(crate) mod look_tests {
|
||||
assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}");
|
||||
}
|
||||
|
||||
/// Each painted row label names its own numeric field rather than the field's internal salt.
|
||||
#[test]
|
||||
fn a_label_rows_input_is_named_by_the_row_label() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -3599,6 +3605,7 @@ pub(crate) mod look_tests {
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "plugin-angle").is_none(), "no internal widget id");
|
||||
}
|
||||
|
||||
/// A label followed by a checkbox or other widget does not name a later field.
|
||||
#[test]
|
||||
fn a_label_does_not_name_a_field_after_another_widget() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -3655,6 +3662,7 @@ pub(crate) mod look_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Enter and focus guard's reconstructed id equals the id egui gives the ComboBox button.
|
||||
#[test]
|
||||
fn a_combos_keyboard_guard_reads_the_combos_own_button_id() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
|
||||
Reference in New Issue
Block a user