Combine source navigation and alias rename with canvas ownership guards
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
//! Keep Iced's single-line input from silently filtering pasted control bytes.
|
||||
//! Only the alias field uses this adapter. The child retains editing and focus.
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
|
||||
};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(AliasInput { input, rejected })
|
||||
}
|
||||
|
||||
struct AliasInput<'a, Message, Theme, R> {
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
modifiers: keyboard::Modifiers,
|
||||
}
|
||||
|
||||
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
|
||||
// previously admitted paste while V is held, until KeyReleased clears that cache.
|
||||
struct CapturedClipboard<'a> {
|
||||
content: Option<String>,
|
||||
original: &'a mut dyn Clipboard,
|
||||
}
|
||||
impl Clipboard for CapturedClipboard<'_> {
|
||||
fn read(&self, kind: clipboard::Kind) -> Option<String> {
|
||||
if kind == clipboard::Kind::Standard {
|
||||
self.content.clone()
|
||||
} else {
|
||||
self.original.read(kind)
|
||||
}
|
||||
}
|
||||
fn write(&mut self, kind: clipboard::Kind, contents: String) {
|
||||
self.original.write(kind, contents);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
|
||||
for AliasInput<'_, Message, Theme, R>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.input.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.input.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(self.input.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.input));
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &R,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.input.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let focused = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused();
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
|
||||
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
|
||||
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
}
|
||||
let paste = matches!(event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
|
||||
if key.to_latin(*physical_key) == Some('v')
|
||||
&& state.modifiers.command() && !state.modifiers.alt());
|
||||
if focused
|
||||
&& paste
|
||||
&& let Some(rejected) = &self.rejected
|
||||
{
|
||||
let content = clipboard.read(clipboard::Kind::Standard);
|
||||
if content
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.chars().any(char::is_control))
|
||||
{
|
||||
// Do not invoke the child: even an empty replacement would erase
|
||||
// its selection. Approval invalidation is an explicit App event.
|
||||
shell.publish(rejected.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
&mut CapturedClipboard {
|
||||
content,
|
||||
original: clipboard,
|
||||
},
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
|
||||
// Escape branch, which also resets stored modifiers. Observe that branch
|
||||
// after its text/shortcut early returns and effective-key conversion.
|
||||
if focused
|
||||
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
|
||||
&& !tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused()
|
||||
{
|
||||
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.input.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,220 @@
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::keyboard::{
|
||||
Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Signal {
|
||||
Input(String),
|
||||
Rejected,
|
||||
}
|
||||
|
||||
struct ClipboardText {
|
||||
text: String,
|
||||
reads: Cell<usize>,
|
||||
}
|
||||
impl Clipboard for ClipboardText {
|
||||
fn read(&self, _: clipboard::Kind) -> Option<String> {
|
||||
self.reads.set(self.reads.get() + 1);
|
||||
Some(self.text.clone())
|
||||
}
|
||||
fn write(&mut self, _: clipboard::Kind, _: String) {}
|
||||
}
|
||||
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
|
||||
|
||||
struct Mounted {
|
||||
input: Element<'static, Signal, iced::Theme, FontLayout>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
clipboard: ClipboardText,
|
||||
messages: Vec<Signal>,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
|
||||
let mut child = iced::widget::text_input("Alias", "send_water");
|
||||
if enabled {
|
||||
child = child.on_input(Signal::Input);
|
||||
}
|
||||
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let node = input.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
|
||||
);
|
||||
if focused {
|
||||
let state = tree.children[0].state.downcast_mut::<InputState>();
|
||||
state.focus();
|
||||
state.select_range(0, 10);
|
||||
}
|
||||
let mut mounted = Self {
|
||||
input,
|
||||
tree,
|
||||
node,
|
||||
clipboard: ClipboardText {
|
||||
text: "foo\nbar".into(),
|
||||
reads: Cell::new(0),
|
||||
},
|
||||
messages: vec![],
|
||||
};
|
||||
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
modifiers,
|
||||
)));
|
||||
mounted
|
||||
}
|
||||
fn state(&self) -> &InputState {
|
||||
self.tree.children[0].state.downcast_ref::<InputState>()
|
||||
}
|
||||
fn event(&mut self, event: Event) -> bool {
|
||||
let mut shell = Shell::new(&mut self.messages);
|
||||
self.input.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
|
||||
);
|
||||
shell.is_event_captured()
|
||||
}
|
||||
}
|
||||
fn key(key: Key, modified_key: Key, code: Code) -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
physical_key: Physical::Code(code),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn paste() -> Event {
|
||||
key(
|
||||
Key::Character("v".into()),
|
||||
Key::Character("v".into()),
|
||||
Code::KeyV,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
|
||||
for focused in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::COMMAND,
|
||||
Modifiers::COMMAND | Modifiers::ALT,
|
||||
Modifiers::COMMAND | Modifiers::SHIFT,
|
||||
] {
|
||||
let mut mounted = Mounted::new(focused, enabled, modifiers);
|
||||
let before = mounted.state().cursor();
|
||||
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
|
||||
let captured = mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
if expected {
|
||||
vec![Signal::Rejected]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
if expected {
|
||||
assert!(captured);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_escape_reset_follows_child_after_early_returns() {
|
||||
// Raw Escape mapped to Home must not clear only the guard's Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Escape),
|
||||
Key::Named(Named::Home),
|
||||
Code::Escape,
|
||||
));
|
||||
assert!(mounted.state().is_focused());
|
||||
assert!(mounted.event(paste()));
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
|
||||
// Effective Escape clears both states; a later focus operation does not
|
||||
// invent a new ModifiersChanged event or revive the previous Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
));
|
||||
assert!(!mounted.state().is_focused());
|
||||
mounted.tree.children[0]
|
||||
.state
|
||||
.downcast_mut::<InputState>()
|
||||
.focus();
|
||||
mounted.event(paste());
|
||||
assert!(mounted.messages.is_empty());
|
||||
assert_eq!(mounted.clipboard.reads.get(), 0);
|
||||
|
||||
// Printable text returns before effective Escape and leaves modifiers live.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
let mut event = key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
);
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
|
||||
*text = Some("x".into());
|
||||
}
|
||||
mounted.event(event);
|
||||
assert!(mounted.state().is_focused());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_paste_cache_contains_only_previously_admitted_text() {
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.clipboard.text = "water".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.clipboard.text = "other".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwater".into())],
|
||||
"Iced reuses its first admitted paste while the key is held"
|
||||
);
|
||||
mounted.messages.clear();
|
||||
let before = mounted.state().cursor();
|
||||
mounted.clipboard.text = "bad\tvalue".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
mounted.messages.clear();
|
||||
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
|
||||
key: Key::Character("v".into()),
|
||||
modified_key: Key::Character("v".into()),
|
||||
physical_key: Physical::Code(Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
}));
|
||||
mounted.clipboard.text = "later".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwaterlater".into())]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Display-only reuse after a guarded alias rename. This is not model authority.
|
||||
use iced::{advanced::text::Renderer, widget::text_editor};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Replace exact, unchanged-line-count source in an existing editor.
|
||||
///
|
||||
/// The caller must first authorize/commit the model change and must reconstruct
|
||||
/// from accepted source on false. Preflight failures do not mutate the editor;
|
||||
/// a failed final readback can leave it changed. On changing success the cursor
|
||||
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
|
||||
/// Direct Content actions deliberately bypass App draft edits and Undo history.
|
||||
pub(super) fn replace<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
before: &str,
|
||||
after: &str,
|
||||
) -> bool {
|
||||
if editor.text() != before {
|
||||
return false;
|
||||
}
|
||||
let Some(old_lines) = lines(before) else {
|
||||
return false;
|
||||
};
|
||||
let Some(new_lines) = lines(after) else {
|
||||
return false;
|
||||
};
|
||||
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
|
||||
return false;
|
||||
}
|
||||
let mut patches = Vec::new();
|
||||
for (line, ((old, old_ending), (new, new_ending))) in
|
||||
old_lines.into_iter().zip(new_lines).enumerate()
|
||||
{
|
||||
let Some(actual) = editor.line(line) else {
|
||||
return false;
|
||||
};
|
||||
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
|
||||
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
|
||||
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
|
||||
return false;
|
||||
}
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
let mut start = old
|
||||
.bytes()
|
||||
.zip(new.bytes())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let mut suffix = old[start..]
|
||||
.bytes()
|
||||
.rev()
|
||||
.zip(new[start..].bytes().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
|
||||
{
|
||||
suffix -= 1;
|
||||
}
|
||||
// All positions and owned payloads are prepared before the first edit.
|
||||
patches.push((
|
||||
line,
|
||||
start,
|
||||
old.len() - suffix,
|
||||
Arc::new(new[start..new.len() - suffix].to_owned()),
|
||||
));
|
||||
}
|
||||
if patches.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let empty = Arc::new(String::new());
|
||||
for (line, start, end, replacement) in patches.into_iter().rev() {
|
||||
select(editor, line, start, end);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
replacement,
|
||||
)));
|
||||
}
|
||||
// Iced overwrites its earliest-change marker on each Edit. An explicit
|
||||
// zero-width selection + empty Paste changes no bytes, but marks line zero.
|
||||
// This also refreshes previously unhighlighted lines before the first patch.
|
||||
// It resets only line zero's text cache; other unchanged lines retain theirs.
|
||||
select(editor, 0, 0, 0);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
|
||||
editor.text() == after
|
||||
}
|
||||
|
||||
fn select<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
line: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) {
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line,
|
||||
column: start,
|
||||
},
|
||||
// move_to(None) does not clear a previous selection in this Iced version.
|
||||
selection: Some(text_editor::Position { line, column: end }),
|
||||
});
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
|
||||
use text_editor::LineEnding;
|
||||
let mut result = Vec::new();
|
||||
let mut parts = source.split('\n').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
let (text, ending) = if parts.peek().is_none() {
|
||||
(part, LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, LineEnding::CrLf)
|
||||
} else {
|
||||
(part, LineEnding::Lf)
|
||||
};
|
||||
if text.contains('\r')
|
||||
|| (text.is_empty()
|
||||
&& ending == LineEnding::CrLf
|
||||
&& result
|
||||
.last()
|
||||
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
result.push((text, ending));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{layout, text::Highlighter, widget::Tree};
|
||||
use iced::{Element, Font, Length, Size};
|
||||
use std::{cell::RefCell, ops::Range};
|
||||
|
||||
#[test]
|
||||
fn exact_text_matrix_and_reverse_preserve_endings() {
|
||||
for (before, after) in [
|
||||
("", ""),
|
||||
("", "alias"),
|
||||
("alias", ""),
|
||||
("same", "same"),
|
||||
("a\n", "alias\n"),
|
||||
("a\r\n", "alias\r\n"),
|
||||
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
|
||||
("\na\n\n", "\nlong_alias\n\n"),
|
||||
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
|
||||
(
|
||||
"雨🙂 e\u{301} @a @a // a",
|
||||
"雨🙂 e\u{301} @a_long @a_long // a",
|
||||
),
|
||||
("@abc", "@abcdef"),
|
||||
("@abcdef", "@abc"),
|
||||
("@abc_suffix", "@def_suffix"),
|
||||
("@none", "@identifier"),
|
||||
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
|
||||
// not be used as String split positions, even outside alias scope.
|
||||
("雨xé", "雪xê"),
|
||||
("é", "©"),
|
||||
(
|
||||
"unchanged\n@a\nunchanged\n@a\n",
|
||||
"unchanged\n@long\nunchanged\n@long\n",
|
||||
),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
assert!(
|
||||
replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), after);
|
||||
let expected = text_editor::Content::<FontLayout>::with_text(after);
|
||||
assert_eq!(editor.line_count(), expected.line_count());
|
||||
for (actual, expected) in editor.lines().zip(expected.lines()) {
|
||||
assert_eq!(actual.text, expected.text);
|
||||
assert_eq!(actual.ending, expected.ending);
|
||||
}
|
||||
if before != after {
|
||||
assert_eq!(
|
||||
editor.cursor().position,
|
||||
text_editor::Position { line: 0, column: 0 }
|
||||
);
|
||||
assert_eq!(editor.cursor().selection, None);
|
||||
}
|
||||
assert!(replace(&mut editor, after, before));
|
||||
assert_eq!(editor.text(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
|
||||
for (actual, before, after) in [
|
||||
("live", "stale", "changed"),
|
||||
("a\nb", "a\nb", "long\nb\nc"),
|
||||
("a\nb", "a\nb", "long\r\nb"),
|
||||
// Refusal on a later line must not apply the earlier planned edit.
|
||||
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
|
||||
("a\rb", "a\rb", "long\rb"),
|
||||
("a\n\rb", "a\n\rb", "long\n\rb"),
|
||||
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
|
||||
// The replacement must not introduce a greedy LFCR boundary either.
|
||||
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
|
||||
("a", "a", "long\r"),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
|
||||
select(&mut editor, 0, 0, 1);
|
||||
let cursor = editor.cursor();
|
||||
let selection = editor.selection();
|
||||
assert!(
|
||||
!replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), actual);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.selection(), selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
|
||||
select(&mut editor, 0, 1, 2);
|
||||
let cursor = editor.cursor();
|
||||
assert!(replace(&mut editor, "@a", "@a"));
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert!(replace(&mut editor, "@a", "@long"));
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
|
||||
assert_eq!(editor.text(), "@long!");
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
struct Tracked(crate::highlight::ArcadiaHighlighter);
|
||||
impl Highlighter for Tracked {
|
||||
type Settings = Arc<str>;
|
||||
type Highlight = oa_core::language::TokenClass;
|
||||
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
|
||||
fn new(settings: &Self::Settings) -> Self {
|
||||
Self(crate::highlight::ArcadiaHighlighter::new(settings))
|
||||
}
|
||||
fn update(&mut self, settings: &Self::Settings) {
|
||||
self.0.update(settings);
|
||||
}
|
||||
fn change_line(&mut self, line: usize) {
|
||||
CHANGES.with(|changes| changes.borrow_mut().push(line));
|
||||
self.0.change_line(line);
|
||||
}
|
||||
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
|
||||
self.0.highlight_line(line)
|
||||
}
|
||||
fn current_line(&self) -> usize {
|
||||
self.0.current_line()
|
||||
}
|
||||
}
|
||||
|
||||
fn view(
|
||||
editor: &text_editor::Content<FontLayout>,
|
||||
source: Arc<str>,
|
||||
) -> Element<'_, (), iced::Theme, FontLayout> {
|
||||
text_editor(editor)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<Tracked>(source, crate::highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
|
||||
let before = "// earlier\n// unmodified\n@a";
|
||||
let after = "// earlier\n// unmodified\n@none";
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
tree
|
||||
};
|
||||
CHANGES.with(|changes| changes.borrow_mut().clear());
|
||||
assert!(replace(&mut editor, before, after));
|
||||
{
|
||||
let mut widget = view(&editor, Arc::from(after));
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
}
|
||||
// Font/metrics/bounds are already stable. This notification must come
|
||||
// from the helper's empty edit, not a fresh renderer initialization.
|
||||
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
|
||||
assert_eq!(editor.text(), after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Combined App-boundary oracles: the workers are completed independently and
|
||||
//! no returned Task is polled. Rendering measurements use the real reveal path.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage};
|
||||
use iced::{Size, Vector};
|
||||
|
||||
mod palette_union;
|
||||
|
||||
fn model_id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn navigation_fixture() -> App {
|
||||
let mut source = bundle();
|
||||
let entry = source.files.get_mut("project.arcadia").unwrap();
|
||||
*entry = entry.replace(
|
||||
"include \"uses.arcadia\";",
|
||||
"include \"uses.arcadia\"; include \"views.arcadia\";",
|
||||
);
|
||||
source.files.insert(
|
||||
"views.arcadia".into(),
|
||||
concat!(
|
||||
"// Keep @pump and the exact distant placement.\r\n",
|
||||
"diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n",
|
||||
"node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n",
|
||||
).into(),
|
||||
);
|
||||
let mut app = fixture();
|
||||
app.document = Document::from_bundle(source.clone(), LIMITS).unwrap();
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(
|
||||
!validate(app.project())
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error)
|
||||
);
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.source_workspace.active = "uses.arcadia".into();
|
||||
app.show_source_file(false);
|
||||
seek_pump(&mut app);
|
||||
app.canvas_size = Some(Size::new(1200., 700.));
|
||||
app.zoom = 0.8;
|
||||
app.pan = Vector::new(31., 47.);
|
||||
app
|
||||
}
|
||||
|
||||
fn seek_pump(app: &mut App) {
|
||||
let source = app.source_workspace.text();
|
||||
let offset = source.rfind("pump;").unwrap();
|
||||
app.source_editor
|
||||
.move_to(cursor(source, offset, offset + 4));
|
||||
}
|
||||
|
||||
fn source_completion(app: &App, request: nav::Request) -> nav::Completion {
|
||||
let snapshot = Arc::new(
|
||||
app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()),
|
||||
);
|
||||
let results = snapshot
|
||||
.query(query::Query {
|
||||
stamp: request.stamp,
|
||||
path: &request.path,
|
||||
byte_offset: request.offset,
|
||||
action: request.action,
|
||||
})
|
||||
.unwrap();
|
||||
nav::Completion {
|
||||
request,
|
||||
result: Ok((snapshot, Arc::new(results))),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagram_completion(app: &App) -> usage::Completion {
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
let process = app.behavior.topology.usage_snapshot().into_query_state();
|
||||
usage::Completion {
|
||||
token: panel.token,
|
||||
context: panel.context,
|
||||
result: Ok(usage::collect(
|
||||
app.project(),
|
||||
panel.context.subject,
|
||||
&process,
|
||||
&behavior::scenario::State::default(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_lookup(app: &mut App) -> (Id, Usage) {
|
||||
let completion = diagram_completion(app);
|
||||
send(app, Message::DiagramUsagesReady(completion));
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
assert!(!panel.loading);
|
||||
assert!(app.diagram_lookup_in_flight.is_none());
|
||||
let found = panel
|
||||
.usages
|
||||
.iter()
|
||||
.find(|u| {
|
||||
u.surface == Surface::Saved(model_id(8))
|
||||
&& u.target == UsageTarget::Placement(model_id(9))
|
||||
})
|
||||
.expect("the exact placed pump is represented")
|
||||
.clone();
|
||||
assert_eq!(found.subject, model_id(2));
|
||||
assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.));
|
||||
(panel.token, found)
|
||||
}
|
||||
|
||||
fn navigate(app: &mut App) -> Usage {
|
||||
let message = app.diagram_usage_message(model_id(2));
|
||||
send(app, message);
|
||||
let (token, found) = finish_lookup(app);
|
||||
send(
|
||||
app,
|
||||
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
|
||||
);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(model_id(8)));
|
||||
assert_eq!(app.selected, model_id(2));
|
||||
assert_eq!(app.selected_node, Some(model_id(9)));
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
found
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Navigation {
|
||||
page: Page,
|
||||
selected: Id,
|
||||
placement: Option<Id>,
|
||||
diagram: Option<Id>,
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
pending: Option<(usage::Context, Usage, u64)>,
|
||||
focus: Option<(usage::Context, Usage, Id)>,
|
||||
canvas_epoch: u64,
|
||||
camera_epoch: u64,
|
||||
source_interaction: Id,
|
||||
source_reveal: Option<Id>,
|
||||
}
|
||||
|
||||
fn navigation(app: &App) -> Navigation {
|
||||
Navigation {
|
||||
page: app.page,
|
||||
selected: app.selected,
|
||||
placement: app.selected_node,
|
||||
diagram: app.active_diagram,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
pending: app.pending_diagram_reveal.clone(),
|
||||
focus: app
|
||||
.diagram_navigation_focus
|
||||
.as_ref()
|
||||
.map(|f| (f.context, f.usage.clone(), f.selected)),
|
||||
canvas_epoch: app.canvas_epoch,
|
||||
camera_epoch: app.camera_epoch,
|
||||
source_interaction: app.source_symbols.interaction,
|
||||
source_reveal: app.source_reveal_ticket(),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_measurement(app: &mut App, found: &Usage) {
|
||||
let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap();
|
||||
let (context, epoch) = (*context, *epoch);
|
||||
let actual = Size::new(500., 300.);
|
||||
send(app, Message::DiagramRevealViewport(context, epoch, actual));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.canvas_size, Some(actual));
|
||||
assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05);
|
||||
assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05);
|
||||
assert_eq!(app.selected, model_id(2));
|
||||
assert_eq!(app.selected_node, Some(model_id(9)));
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() {
|
||||
let mut app = navigation_fixture();
|
||||
let original = evidence(&app);
|
||||
let snapshot = app.document.snapshot();
|
||||
let queued = app.diagram_usage_message(model_id(2));
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let confirm = preview(&mut app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let approved = (
|
||||
form.invocation,
|
||||
form.generation,
|
||||
form.approval.as_ref().unwrap().id,
|
||||
);
|
||||
send(&mut app, queued);
|
||||
let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
|
||||
send(&mut app, source);
|
||||
let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(&mut app, definition);
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert!(app.diagram_lookup_in_flight.is_none());
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(!app.source_navigation_available());
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
form.invocation,
|
||||
form.generation,
|
||||
form.approval.as_ref().unwrap().id
|
||||
),
|
||||
approved
|
||||
);
|
||||
assert_eq!(form.replacement, "send_water");
|
||||
assert_eq!(evidence(&app), original);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
send(&mut app, Message::Escape);
|
||||
send(&mut app, confirm);
|
||||
assert!(!app.source_rename.open());
|
||||
assert_eq!(evidence(&app), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() {
|
||||
for finish in [false, true] {
|
||||
let mut app = navigation_fixture();
|
||||
let before = evidence(&app);
|
||||
let queued_rename = app.source_rename_message();
|
||||
let open = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, open);
|
||||
if finish {
|
||||
finish_lookup(&mut app);
|
||||
}
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let loading = panel.loading;
|
||||
assert!(app.rename_admission().is_err());
|
||||
assert!(!app.source_navigation_available());
|
||||
send(&mut app, queued_rename);
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
assert_eq!((panel.token, panel.loading), (token, loading));
|
||||
assert_eq!(evidence(&app), before);
|
||||
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
|
||||
let rename = app.source_rename_message();
|
||||
send(&mut app, rename);
|
||||
assert!(
|
||||
app.source_rename.open(),
|
||||
"the dismissed chooser no longer owns input"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() {
|
||||
for preview_pending in [false, true] {
|
||||
let mut app = navigation_fixture();
|
||||
let before = evidence(&app);
|
||||
let completion = if preview_pending {
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let job = start_preview(&mut app);
|
||||
preview_completion(&app, job)
|
||||
} else {
|
||||
let rename = app.source_rename_message();
|
||||
send(&mut app, rename);
|
||||
target_completion(&app, current_job(&app))
|
||||
};
|
||||
let occupied = completion.job.id;
|
||||
event(&mut app, Event::Close(completion.job.invocation));
|
||||
assert_eq!(app.source_rename.in_flight, Some(occupied));
|
||||
let found = navigate(&mut app);
|
||||
let location = navigation(&app);
|
||||
assert_eq!(evidence(&app), before);
|
||||
let mut forged = completion.clone();
|
||||
forged.job.id = Id::new_v4();
|
||||
send(&mut app, Message::SourceRenameReady(forged));
|
||||
assert_eq!(app.source_rename.in_flight, Some(occupied));
|
||||
assert_eq!(navigation(&app), location);
|
||||
send(&mut app, Message::SourceRenameReady(completion.clone()));
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert!(!app.source_rename.open());
|
||||
assert_eq!(navigation(&app), location);
|
||||
assert_eq!(evidence(&app), before);
|
||||
send(&mut app, Message::SourceRenameReady(completion));
|
||||
assert_eq!(
|
||||
navigation(&app),
|
||||
location,
|
||||
"duplicate completion is passive too"
|
||||
);
|
||||
first_measurement(&mut app, &found);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged()
|
||||
{
|
||||
let mut app = navigation_fixture();
|
||||
let before = evidence(&app);
|
||||
let snapshot = app.document.snapshot();
|
||||
let queued_begin = app.source_rename_message();
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let confirm = preview(&mut app);
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
event(&mut app, Event::Close(invocation));
|
||||
let found = navigate(&mut app);
|
||||
let location = navigation(&app);
|
||||
for stale in [
|
||||
queued_begin,
|
||||
Message::SourceRename(Event::Close(invocation)),
|
||||
confirm,
|
||||
] {
|
||||
send(&mut app, stale);
|
||||
assert_eq!(navigation(&app), location);
|
||||
assert_eq!(evidence(&app), before);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
}
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() {
|
||||
let mut app = navigation_fixture();
|
||||
let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(&mut app, source);
|
||||
let request = app.source_symbols.request.clone().unwrap();
|
||||
let source_ready = source_completion(&app, request.clone());
|
||||
let rename = app.source_rename_message();
|
||||
send(&mut app, rename);
|
||||
let rename_ready = target_completion(&app, current_job(&app));
|
||||
let rename_job = rename_ready.job.id;
|
||||
event(&mut app, Event::Close(rename_ready.job.invocation));
|
||||
let open = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, open);
|
||||
let old_diagram = diagram_completion(&app);
|
||||
let diagram_job = old_diagram.token;
|
||||
let old_document = app.canvas_document;
|
||||
let project = app.project().clone();
|
||||
app.reset(project, None);
|
||||
assert_ne!(app.canvas_document, old_document);
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.source_rename.in_flight, Some(rename_job));
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.diagram_usages.is_none());
|
||||
let before = evidence(&app);
|
||||
let snapshot = app.document.snapshot();
|
||||
let new_open = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, new_open);
|
||||
let new_token = app.diagram_usages.as_ref().unwrap().token;
|
||||
assert_ne!(new_token, diagram_job);
|
||||
assert_eq!(
|
||||
app.diagram_lookup_in_flight,
|
||||
Some(diagram_job),
|
||||
"replacement waits for old worker"
|
||||
);
|
||||
send(&mut app, Message::SourceRenameReady(rename_ready));
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
|
||||
send(&mut app, Message::SourceSymbolsReady(source_ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(!app.source_symbols.open);
|
||||
send(&mut app, Message::DiagramUsagesReady(old_diagram));
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(new_token));
|
||||
assert!(app.diagram_usages.as_ref().unwrap().loading);
|
||||
finish_lookup(&mut app);
|
||||
assert_eq!(evidence(&app), before);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert!(!app.source_rename.open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() {
|
||||
let mut app = navigation_fixture();
|
||||
let baseline = app.document.snapshot();
|
||||
let project = app.project().id;
|
||||
app.document
|
||||
.execute(Command::Rename(project, "Previously accepted name".into()))
|
||||
.unwrap();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
seek_pump(&mut app);
|
||||
let before = app.document.snapshot();
|
||||
let mut expected_project = app.project().clone();
|
||||
expected_project
|
||||
.elements
|
||||
.get_mut(&model_id(2))
|
||||
.unwrap()
|
||||
.alias = "send_water".into();
|
||||
let mut expected_source = app.document.bundle().clone();
|
||||
let defs = expected_source.files.get_mut("defs.arcadia").unwrap();
|
||||
*defs = defs.replace("oa_activity pump ", "oa_activity send_water ");
|
||||
let uses = expected_source.files.get_mut("uses.arcadia").unwrap();
|
||||
*uses = uses
|
||||
.replace("source @pump;", "source @send_water;")
|
||||
.replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;");
|
||||
let views = expected_source.files.get_mut("views.arcadia").unwrap();
|
||||
*views = views.replace("element @pump;", "element @send_water;");
|
||||
begin(&mut app);
|
||||
input(&mut app, "send_water");
|
||||
let confirm = preview(&mut app);
|
||||
send(&mut app, confirm);
|
||||
assert_eq!(app.project(), &expected_project);
|
||||
assert_eq!(app.document.bundle(), &expected_source);
|
||||
let renamed = app.document.snapshot();
|
||||
let accepted = evidence(&app);
|
||||
let queued_undo_rename = app
|
||||
.source_rename_undo_message()
|
||||
.expect("fresh rename offers its guarded undo");
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
|
||||
send(&mut app, message);
|
||||
let request = app
|
||||
.source_symbols
|
||||
.request
|
||||
.clone()
|
||||
.expect("renamed exact caret remains queryable");
|
||||
let ready = source_completion(&app, request);
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
let (token, found) = finish_lookup(&mut app);
|
||||
send(
|
||||
&mut app,
|
||||
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
|
||||
);
|
||||
assert_eq!(app.selected, model_id(2));
|
||||
assert_eq!(app.selected_node, Some(model_id(9)));
|
||||
let destination = navigation(&app);
|
||||
send(&mut app, queued_undo_rename);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(evidence(&app), accepted);
|
||||
first_measurement(&mut app, &found);
|
||||
assert_eq!(evidence(&app), accepted);
|
||||
assert!(Arc::ptr_eq(&renamed, &app.document.snapshot()));
|
||||
for (message, expected) in [
|
||||
(Message::Undo, before.clone()),
|
||||
(Message::Redo, renamed.clone()),
|
||||
(Message::Undo, before.clone()),
|
||||
(Message::Undo, baseline.clone()),
|
||||
(Message::Redo, before),
|
||||
(Message::Redo, renamed),
|
||||
] {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
Arc::ptr_eq(&expected, &app.document.snapshot()),
|
||||
"exact history entry restored"
|
||||
);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), expected.bundle);
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Interactions absent from the independently published navigation branch.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() {
|
||||
let mut app = navigation_fixture();
|
||||
let before = app.document.snapshot();
|
||||
let notify = || Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary);
|
||||
send(&mut app, notify());
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
send(&mut app, notify());
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
first_measurement(&mut app, &found);
|
||||
send(&mut app, notify());
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_rename_begin_cannot_retire_allocation_epoch_or_new_navigation() {
|
||||
let mut app = navigation_fixture();
|
||||
let captured = app.source_rename_begin();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::SourceRename(Event::Begin(captured)));
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
|
||||
let mut project = Project::empty();
|
||||
let scenario = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Prior scenario", None)
|
||||
.unwrap();
|
||||
let mut previous = App::with_project(project);
|
||||
previous.page = Page::Behavior;
|
||||
previous.behavior.active_owner = Some(scenario);
|
||||
previous
|
||||
.behavior
|
||||
.scenario
|
||||
.enter(previous.canvas_document, scenario);
|
||||
let scope = behavior::canvas_workspace::Scope::input_owner(&previous).unwrap();
|
||||
let captured = behavior::canvas_workspace::intent(
|
||||
scope,
|
||||
"Fit",
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Zoom(
|
||||
previous.scenario_context().unwrap(),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let mut app = navigation_fixture();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, captured);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Real paragraph advances and clipping through the actual preview dialog.
|
||||
//! Native screenshots additionally check raster ink and the platform renderer.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
|
||||
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
struct Glyph {
|
||||
text: String,
|
||||
paragraph_y: u32,
|
||||
line: usize,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
|
||||
struct GlyphRecorder {
|
||||
wanted: BTreeSet<String>,
|
||||
transforms: Vec<Transformation>,
|
||||
clips: Vec<Rectangle>,
|
||||
glyphs: Vec<Glyph>,
|
||||
quads: Vec<Rectangle>,
|
||||
}
|
||||
impl GlyphRecorder {
|
||||
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
|
||||
Self {
|
||||
wanted,
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clips: vec![viewport],
|
||||
glyphs: vec![],
|
||||
quads: vec![],
|
||||
}
|
||||
}
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for GlyphRecorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clips = vec![bounds];
|
||||
self.glyphs.clear();
|
||||
self.quads.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
|
||||
self.quads.push(quad.bounds * self.transform());
|
||||
}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for GlyphRecorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let text = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !self.wanted.contains(&text) {
|
||||
return;
|
||||
}
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
if run.text[glyph.start..glyph.end]
|
||||
.chars()
|
||||
.all(char::is_whitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let bounds = Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform();
|
||||
self.glyphs.push(Glyph {
|
||||
text: text.clone(),
|
||||
paragraph_y: position.y.to_bits(),
|
||||
line: run.line_i,
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds,
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(
|
||||
&mut self,
|
||||
editor: &Self::Editor,
|
||||
position: Point,
|
||||
color: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
|
||||
}
|
||||
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
|
||||
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScrollGeometry {
|
||||
id: Option<widget::Id>,
|
||||
bounds: Option<Rectangle>,
|
||||
content: Option<Rectangle>,
|
||||
offset: Vector,
|
||||
}
|
||||
impl widget::Operation for ScrollGeometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == self.id.as_ref() {
|
||||
self.bounds = Some(bounds);
|
||||
self.content = Some(content);
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn normalized(text: &str) -> String {
|
||||
text.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
|
||||
text.split('\n')
|
||||
.enumerate()
|
||||
.flat_map(|(line, text)| {
|
||||
text.char_indices()
|
||||
.filter(|(_, ch)| !ch.is_whitespace())
|
||||
.map(move |(byte, _)| (line, byte))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declarations(long: bool) -> App {
|
||||
let name = if long {
|
||||
format!("Assess {} request", "界🚀".repeat(90))
|
||||
} else {
|
||||
"Assess 救援 request".into()
|
||||
};
|
||||
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
|
||||
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
|
||||
]) };
|
||||
let mut app = fixture();
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "救援/assess.arcadia".into();
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.show_source_file(false);
|
||||
let start = app
|
||||
.source_workspace
|
||||
.text()
|
||||
.find("oa_activity assess")
|
||||
.unwrap()
|
||||
+ "oa_activity ".len();
|
||||
app.source_editor.move_to(cursor(
|
||||
app.source_workspace.text(),
|
||||
start,
|
||||
start + "assess".len(),
|
||||
));
|
||||
begin(&mut app);
|
||||
let replacement = if long {
|
||||
"a".repeat(255)
|
||||
} else {
|
||||
"assess_damage".into()
|
||||
};
|
||||
input(&mut app, &replacement);
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn assert_page_glyphs_visible(app: &App, host: Size) {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let ready = form.approval.as_ref().unwrap();
|
||||
let file = &ready.preview.files()[form.file];
|
||||
let mut expected = BTreeMap::<String, usize>::new();
|
||||
for line in file.hunks()[form.hunk]
|
||||
.lines()
|
||||
.iter()
|
||||
.skip(form.line)
|
||||
.take(LINES_PER_PAGE)
|
||||
{
|
||||
for source in [
|
||||
line.before_text(&ready.prepared.before().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
line.after_text(&ready.prepared.after().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
] {
|
||||
if let Some(fragment) = preview::text_segment(source, form.segment) {
|
||||
let text = normalized(fragment);
|
||||
if !required_chars(&text).is_empty() {
|
||||
*expected.entry(text).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
let viewport = Rectangle::new(Point::ORIGIN, host);
|
||||
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
|
||||
let editor = text_editor::Content::<GlyphRecorder>::new();
|
||||
let mut view = dialog_content(app, &editor);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
|
||||
let mut geometry = ScrollGeometry {
|
||||
id: Some(preview_scroll_id(form, ready.id)),
|
||||
..ScrollGeometry::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
let bounds = geometry.bounds.unwrap();
|
||||
let content = geometry.content.unwrap();
|
||||
assert!(bounds.height >= 40.);
|
||||
let maximum = (content.height - bounds.height).max(0.);
|
||||
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
|
||||
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
|
||||
let mut reserved_bar_gap = true;
|
||||
for step in 0..=steps {
|
||||
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
preview_scroll_id(form, ready.id),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
assert!(
|
||||
(geometry.offset.y - offset).abs() <= 0.5,
|
||||
"actual scroll operation reaches the requested source offset"
|
||||
);
|
||||
renderer::Renderer::reset(&mut renderer, viewport);
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
// The vertical scrollbar's actual painted thumb fixes its excluded x
|
||||
// region; the outer scroll clip alone cannot detect overpainting.
|
||||
let scrollbar_left = renderer
|
||||
.quads
|
||||
.iter()
|
||||
.filter(|quad| {
|
||||
quad.width > 0.
|
||||
&& quad.width <= 16.
|
||||
&& quad.height > 0.
|
||||
&& quad.x >= bounds.x + bounds.width - 18.
|
||||
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
|
||||
&& quad.y >= bounds.y - 0.5
|
||||
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
|
||||
})
|
||||
.map(|quad| quad.x)
|
||||
.min_by(f32::total_cmp);
|
||||
if maximum > 0. {
|
||||
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
|
||||
reserved_bar_gap &= content.x + content.width <= left - 7.5;
|
||||
}
|
||||
for glyph in &renderer.glyphs {
|
||||
let right = glyph.bounds.x + glyph.bounds.width;
|
||||
if let Some(left) = scrollbar_left {
|
||||
assert!(
|
||||
right <= left - 0.5,
|
||||
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
|
||||
glyph.text,
|
||||
glyph.bytes,
|
||||
glyph.bounds
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
|
||||
"source glyph escaped reserved width: {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds
|
||||
);
|
||||
assert!(
|
||||
glyph.bounds.x >= glyph.clip.x - 0.5
|
||||
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
|
||||
"source glyph horizontally clipped: {:?} {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds,
|
||||
glyph.clip
|
||||
);
|
||||
if glyph.bounds.y >= glyph.clip.y - 0.5
|
||||
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
|
||||
{
|
||||
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
|
||||
let chars = logical_line
|
||||
.char_indices()
|
||||
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
|
||||
.map(|(byte, _)| (glyph.line, byte));
|
||||
visible
|
||||
.entry((glyph.text.clone(), glyph.paragraph_y))
|
||||
.or_default()
|
||||
.extend(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
reserved_bar_gap,
|
||||
"source content must reserve actual scrollbar width plus gap"
|
||||
);
|
||||
assert!(
|
||||
bounds.width >= host.width - 30.,
|
||||
"preview must use available dialog width: {bounds:?} / {host:?}"
|
||||
);
|
||||
for (text, count) in expected {
|
||||
let required = required_chars(&text);
|
||||
let instances: Vec<_> = visible
|
||||
.iter()
|
||||
.filter(|((value, _), _)| value == &text)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
instances.len(),
|
||||
count,
|
||||
"all before/after occurrences must be seen: {text:?}"
|
||||
);
|
||||
for (_, seen) in instances {
|
||||
assert_eq!(
|
||||
seen, &required,
|
||||
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
|
||||
for long in [false, true] {
|
||||
let mut app = declarations(long);
|
||||
let ready = app
|
||||
.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
let before = evidence(&app);
|
||||
for (width, height) in [(1100., 700.), (580., 700.)] {
|
||||
app.window_size = Size::new(width, height);
|
||||
let host = Size::new((width - 84.).min(708.), height - 120.);
|
||||
let total = segment_total(app.source_rename.form.as_ref().unwrap());
|
||||
if long {
|
||||
assert!(
|
||||
total >= 3,
|
||||
"include unbroken ASCII and wide Unicode across segments"
|
||||
);
|
||||
}
|
||||
for segment in 0..total {
|
||||
assert_page_glyphs_visible(&app, host);
|
||||
if segment + 1 < total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, true));
|
||||
}
|
||||
}
|
||||
for _ in 1..total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, false));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
evidence(&app),
|
||||
before,
|
||||
"preview geometry/paging cannot alter transaction inputs"
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
ready.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Execute the controller's scroll operations on a retained, scrolled dialog.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
|
||||
const HOST: Size = Size::new(720., 660.);
|
||||
|
||||
fn paged_fixture() -> App {
|
||||
let mut source = bundle();
|
||||
let name = "Long engineering name 雨 ".repeat(18);
|
||||
source.files.get_mut("defs.arcadia").unwrap().replace_range(
|
||||
..,
|
||||
&format!(
|
||||
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
|
||||
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
|
||||
),
|
||||
);
|
||||
let mut uses = String::new();
|
||||
for index in 0..16 {
|
||||
if index == 12 {
|
||||
uses.push_str(&"// unchanged gap\r\n".repeat(10));
|
||||
}
|
||||
uses.push_str(&format!(
|
||||
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
|
||||
));
|
||||
}
|
||||
source.files.insert("uses.arcadia".into(), uses);
|
||||
let mut app = fixture();
|
||||
app.window_size = HOST;
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "uses.arcadia".into();
|
||||
app.show_source_file(false);
|
||||
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
|
||||
app.source_editor
|
||||
.move_to(cursor(app.source_workspace.text(), start, start + 4));
|
||||
begin(&mut app);
|
||||
input(&mut app, "pump_long");
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn scroll_id(app: &App) -> widget::Id {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
|
||||
}
|
||||
|
||||
struct Geometry {
|
||||
id: widget::Id,
|
||||
offset: Option<Vector>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.id) {
|
||||
self.offset = Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
editor: text_editor::Content<FontLayout>,
|
||||
tree: widget::Tree,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(app: &App) -> Self {
|
||||
let editor = text_editor::Content::<FontLayout>::new();
|
||||
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
|
||||
Self { editor, tree }
|
||||
}
|
||||
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut view = dialog_content(app, &self.editor);
|
||||
// Preserve the actual scroll state across the new page's view diff.
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
|
||||
}
|
||||
fn offset(&mut self, app: &App) -> f32 {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
geometry.offset.expect("mounted preview scrollable").y
|
||||
}
|
||||
fn scroll_down(&mut self, app: &App) -> f32 {
|
||||
self.operate(
|
||||
app,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
scroll_id(app),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(10_000.),
|
||||
},
|
||||
),
|
||||
);
|
||||
let offset = self.offset(app);
|
||||
assert!(offset > 10., "fixture must really be scrolled");
|
||||
offset
|
||||
}
|
||||
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
|
||||
let mut count = 0;
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("preview paging must only schedule a widget operation");
|
||||
};
|
||||
self.operate(app, operation.as_mut());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
mounted.scroll_down(&app);
|
||||
let old_id = scroll_id(&app);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_ne!(old_id, scroll_id(&app));
|
||||
// Negative control: even after diff/layout with the new id and new file,
|
||||
// Iced retains the previous offset until the returned operation executes.
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
|
||||
for navigation in [
|
||||
Event::Line(invocation, approval, true),
|
||||
Event::Segment(invocation, approval, true),
|
||||
Event::Hunk(invocation, approval, true),
|
||||
Event::File(invocation, approval, false),
|
||||
Event::Search(invocation, "uses".into()),
|
||||
Event::Search(invocation, "no-path-match".into()),
|
||||
Event::Search(invocation, String::new()),
|
||||
] {
|
||||
mounted.scroll_down(&app);
|
||||
let task = app.update(Message::SourceRename(navigation));
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
// Passive redraw and unchanged filter input preserve deliberate scrolling.
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
String::new(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Input(
|
||||
invocation,
|
||||
"pump_long".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"clamped first-page request is a no-op"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let old_approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let old_page_task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
let next_page_task = app.update(Message::SourceRename(Event::Line(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
|
||||
|
||||
let old_approval_task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"defs".into(),
|
||||
)));
|
||||
input(&mut app, "pump_new");
|
||||
let _ = preview(&mut app); // Real new approval in the same invocation/page.
|
||||
assert_ne!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
old_approval
|
||||
);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old approval target is absent"
|
||||
);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"stale envelope is rejected before scheduling"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
|
||||
let first_visit = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
let next_page = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page).await, 1);
|
||||
let return_visit = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, return_visit).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, first_visit).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old visit to the identical page is stale"
|
||||
);
|
||||
|
||||
let old_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"uses".into(),
|
||||
)));
|
||||
let new_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"us".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, new_filter).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_filter).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old filter on the same page is stale"
|
||||
);
|
||||
|
||||
input(&mut app, "pump_new");
|
||||
let job = start_preview(&mut app);
|
||||
let completion = preview_completion(&app, job);
|
||||
// No intermediate frame mounts the temporary no-approval view. The old
|
||||
// Scrollable survives even though the new approval has a different id.
|
||||
let ready = app.update(Message::SourceRenameReady(completion));
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, ready).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll()
|
||||
{
|
||||
// Exercise both an actually mounted Details view and a fast round trip
|
||||
// between frames, where Iced retains the same preview Scrollable state.
|
||||
for mount_details in [true, false] {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
mounted.scroll_down(&app);
|
||||
let held_reset = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
let old_preview = scroll_id(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let page = (form.file, form.hunk, form.line, form.segment);
|
||||
|
||||
let show_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
||||
assert_eq!(mounted.execute(&app, show_details).await, 0);
|
||||
let details_visit = scroll_id(&app);
|
||||
assert_ne!(details_visit, old_preview);
|
||||
if mount_details {
|
||||
let mut hidden = Geometry {
|
||||
id: old_preview.clone(),
|
||||
offset: None,
|
||||
};
|
||||
mounted.operate(&app, &mut hidden);
|
||||
assert!(
|
||||
hidden.offset.is_none(),
|
||||
"Details actually replaced the preview"
|
||||
);
|
||||
}
|
||||
let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
||||
assert_eq!(mounted.execute(&app, repeat_details).await, 0);
|
||||
assert_eq!(
|
||||
scroll_id(&app),
|
||||
details_visit,
|
||||
"unchanged visibility keeps its visit"
|
||||
);
|
||||
|
||||
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, show_preview).await,
|
||||
0,
|
||||
"returning does not force a scroll reset"
|
||||
);
|
||||
let returned_visit = scroll_id(&app);
|
||||
assert_ne!(returned_visit, old_preview);
|
||||
assert_ne!(returned_visit, details_visit);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
|
||||
assert_eq!(form.approval.as_ref().unwrap().id, approval);
|
||||
|
||||
let offset = mounted.scroll_down(&app);
|
||||
let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
||||
assert_eq!(mounted.execute(&app, repeat_preview).await, 0);
|
||||
assert_eq!(scroll_id(&app), returned_visit);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"repeated Preview preserves deliberate scroll"
|
||||
);
|
||||
assert_eq!(mounted.execute(&app, held_reset).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"the previous visit cannot reset the returned preview"
|
||||
);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user