Integrate persistent canvas controls with scenario lifetime rendering
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
//! Live form adapter. Compatibility stays in the existing parent adapter;
|
||||
//! browser metadata and scoped writes never reinterpret historical UUID text.
|
||||
use super::super::{Draft, friendly, history_fields, optional_reference, reference, reference_ids};
|
||||
use super::{Candidate, Context, Current, State, trigger};
|
||||
use crate::{app::Message, style};
|
||||
use iced::{
|
||||
Element, Fill, Rectangle,
|
||||
widget::{column, container, row, text, tooltip},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Open {
|
||||
pub document: Id,
|
||||
pub session: Id,
|
||||
pub generation: Id,
|
||||
pub record: Id,
|
||||
pub path: String,
|
||||
pub anchor: Rectangle,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Set {
|
||||
pub context: Context,
|
||||
pub generation: Id,
|
||||
pub value: Option<Id>,
|
||||
}
|
||||
impl Draft {
|
||||
pub fn reference_session(&self) -> Id {
|
||||
self.reference_session
|
||||
}
|
||||
pub fn reference_generation(&self) -> Id {
|
||||
self.reference_generation
|
||||
}
|
||||
pub fn reference_open(&self, project: &Project, path: &str, anchor: Rectangle) -> Open {
|
||||
Open {
|
||||
document: project.id,
|
||||
session: self.reference_session,
|
||||
generation: self.reference_generation,
|
||||
record: self.original,
|
||||
path: path.into(),
|
||||
anchor,
|
||||
}
|
||||
}
|
||||
pub fn reference_matches(&self, request: &Open) -> bool {
|
||||
self.reference_session == request.session
|
||||
&& self.reference_generation == request.generation
|
||||
&& self.original == request.record
|
||||
&& self.live_reference(&request.path)
|
||||
}
|
||||
fn live_reference(&self, path: &str) -> bool {
|
||||
!history_fields::is_path(path)
|
||||
&& reference(path)
|
||||
&& self
|
||||
.value
|
||||
.pointer(path)
|
||||
.is_some_and(|v| v.is_string() || v.is_null())
|
||||
}
|
||||
pub fn reference_catalog(
|
||||
&self,
|
||||
project: &Project,
|
||||
request: &Open,
|
||||
revision: u64,
|
||||
) -> Result<State, String> {
|
||||
if request.document != project.id || !self.reference_matches(request) {
|
||||
return Err("The reference field changed. Open its chooser again.".into());
|
||||
}
|
||||
let allowed: BTreeSet<_> = reference_ids(self, project, &request.path)
|
||||
.into_iter()
|
||||
.filter(|id| !id.is_nil())
|
||||
.collect();
|
||||
let candidates = allowed
|
||||
.iter()
|
||||
.filter_map(|id| candidate(project, *id))
|
||||
.collect();
|
||||
let raw = self
|
||||
.inputs
|
||||
.get(&request.path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| self.value.pointer(&request.path).and_then(Value::as_str));
|
||||
let current = match raw {
|
||||
None => Current::Unassigned,
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => Current::Unassigned,
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) if allowed.contains(&id) => Current::Valid(c),
|
||||
Some(c) => Current::Incompatible(c),
|
||||
None => Current::Missing(id),
|
||||
},
|
||||
Err(_) => Current::InvalidRaw(raw.into()),
|
||||
},
|
||||
};
|
||||
let context = Context::live(
|
||||
project.id,
|
||||
revision,
|
||||
self.reference_session,
|
||||
self.original,
|
||||
&request.path,
|
||||
optional_reference(&request.path),
|
||||
)
|
||||
.map_err(str::to_owned)?;
|
||||
State::open(
|
||||
context,
|
||||
candidates,
|
||||
current,
|
||||
format!(
|
||||
"{} · compatible references",
|
||||
friendly(request.path.rsplit('/').next().unwrap_or("Reference"))
|
||||
),
|
||||
)
|
||||
.map_err(str::to_owned)
|
||||
}
|
||||
pub fn reference_set(&mut self, project: &Project, intent: &Set) -> Result<(), String> {
|
||||
let value = self.reference_value(project, intent)?;
|
||||
self.shape_scoped(&intent.context.path, value)
|
||||
}
|
||||
pub fn reference_value(&self, project: &Project, intent: &Set) -> Result<Value, String> {
|
||||
let context = &intent.context;
|
||||
if context.document != project.id
|
||||
|| context.draft != self.reference_session
|
||||
|| context.record != self.original
|
||||
|| intent.generation != self.reference_generation
|
||||
|| !self.live_reference(&context.path)
|
||||
|| context.optional != optional_reference(&context.path)
|
||||
{
|
||||
return Err("The originating reference field is no longer current.".into());
|
||||
}
|
||||
let value = match intent.value {
|
||||
Some(id)
|
||||
if !id.is_nil() && reference_ids(self, project, &context.path).contains(&id) =>
|
||||
{
|
||||
json!(id)
|
||||
}
|
||||
None if context.optional => Value::Null,
|
||||
_ => return Err("Choose a currently compatible reference.".into()),
|
||||
};
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn named(project: &Project, id: Id) -> String {
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
format!("{} · @{}", e.name, e.alias)
|
||||
} else {
|
||||
project.named(id).into()
|
||||
}
|
||||
}
|
||||
fn ancestry(project: &Project, id: Id) -> String {
|
||||
let mut parts = vec![];
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
parts.push("[containment cycle]".into());
|
||||
break;
|
||||
}
|
||||
parts.push(named(project, id));
|
||||
current = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
parts.reverse();
|
||||
if parts.is_empty() {
|
||||
project.name.clone()
|
||||
} else {
|
||||
parts.join(" / ")
|
||||
}
|
||||
}
|
||||
pub(crate) fn candidate(project: &Project, id: Id) -> Option<Candidate> {
|
||||
let layer = |layer| format!("{layer:?}").to_uppercase();
|
||||
if id == project.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.name.clone(),
|
||||
alias: project.alias.clone(),
|
||||
kind: "Project".into(),
|
||||
scope: "Model".into(),
|
||||
containment: "Model root".into(),
|
||||
});
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: e.name.clone(),
|
||||
alias: e.alias.clone(),
|
||||
kind: e.kind().to_string(),
|
||||
scope: layer(e.layer()),
|
||||
containment: ancestry(project, id),
|
||||
});
|
||||
}
|
||||
if let Some(r) = project.relations.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: r.name.clone(),
|
||||
alias: r.alias.clone(),
|
||||
kind: r.kind().to_string(),
|
||||
scope: project.layer_of(id).map_or_else(|| "Model".into(), layer),
|
||||
containment: format!(
|
||||
"{} → {}",
|
||||
named(project, r.source()),
|
||||
named(project, r.target())
|
||||
),
|
||||
});
|
||||
}
|
||||
for diagram in project.diagrams.values() {
|
||||
if id == diagram.id {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: diagram.name.clone(),
|
||||
alias: diagram.alias.clone(),
|
||||
kind: format!("{:?} diagram", diagram.projection),
|
||||
scope: layer(diagram.layer),
|
||||
containment: project.name.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(node) = diagram.nodes.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: project.named(node.element).into(),
|
||||
alias: node.alias.clone(),
|
||||
kind: "Placement".into(),
|
||||
scope: layer(diagram.layer),
|
||||
containment: format!(
|
||||
"{} · @{} / {}",
|
||||
diagram.name,
|
||||
diagram.alias,
|
||||
named(project, node.element)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(link) = project.artifact_links.get(&id) {
|
||||
return Some(Candidate {
|
||||
id,
|
||||
name: link.name.clone(),
|
||||
alias: link.alias.clone(),
|
||||
kind: "Artifact link".into(),
|
||||
scope: project
|
||||
.layer_of(link.owner)
|
||||
.map_or_else(|| "Model".into(), layer),
|
||||
containment: named(project, link.owner),
|
||||
});
|
||||
}
|
||||
project.traces.get(&id).map(|trace| Candidate {
|
||||
id,
|
||||
name: trace.name.clone(),
|
||||
alias: trace.alias.clone(),
|
||||
kind: "Trace".into(),
|
||||
scope: "OA ↔ SA".into(),
|
||||
containment: format!(
|
||||
"{} ↔ {}",
|
||||
named(project, trace.oa),
|
||||
named(project, trace.sa)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn field<'a, R: iced::advanced::text::Renderer<Font = iced::Font> + 'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
path: &str,
|
||||
) -> Element<'a, Message, iced::Theme, R> {
|
||||
let raw = draft
|
||||
.inputs
|
||||
.get(path)
|
||||
.map(|v| v.raw.as_str())
|
||||
.or_else(|| draft.value.pointer(path).and_then(Value::as_str));
|
||||
let (label, detail) = match raw {
|
||||
None => (
|
||||
if optional_reference(path) {
|
||||
"Not assigned"
|
||||
} else {
|
||||
"Choose a reference…"
|
||||
}
|
||||
.into(),
|
||||
String::new(),
|
||||
),
|
||||
Some(raw) => match raw.parse::<Id>() {
|
||||
Ok(id) if id.is_nil() => ("Choose a reference…".into(), String::new()),
|
||||
Ok(id) => match candidate(project, id) {
|
||||
Some(c) => (format!("{} · @{}", c.name, c.alias), c.detail()),
|
||||
None => ("Missing reference".into(), raw.into()),
|
||||
},
|
||||
Err(_) => ("Invalid reference retained".into(), raw.into()),
|
||||
},
|
||||
};
|
||||
let label = crate::views::short(&label, 35);
|
||||
let body = container(row![text(label).size(12).width(Fill), text("⌄").size(12)].spacing(6))
|
||||
.padding(8)
|
||||
.width(Fill);
|
||||
let path = path.to_owned();
|
||||
let id = trigger::field_id(draft.reference_generation, &path);
|
||||
let field = trigger::Field::new(body, id, move |anchor| {
|
||||
Message::FieldReferenceOpen(draft.reference_open(project, &path, anchor))
|
||||
});
|
||||
if detail.is_empty() {
|
||||
field.into()
|
||||
} else {
|
||||
tooltip(
|
||||
field,
|
||||
container(column![text(detail).size(11)])
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
tooltip::Position::Left,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element, Event as Input, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{Layout, Shell, layout, widget::Tree},
|
||||
keyboard, mouse,
|
||||
widget::{Space, mouse_area},
|
||||
};
|
||||
|
||||
fn context(optional: bool) -> Context {
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
7,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
"/body/SequenceUse/sequence",
|
||||
optional,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn candidate(index: u128) -> Candidate {
|
||||
Candidate {
|
||||
id: Id::from_u128(10_000 + index),
|
||||
name: "Control".into(),
|
||||
alias: format!("controller_{index:04}"),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: format!("System / Subsystem {} / Controller", index / 100),
|
||||
}
|
||||
}
|
||||
fn thousand(current: Current, optional: bool) -> State {
|
||||
// Reverse the fixture input to falsify accidental dependence on ingestion
|
||||
// order. Expected IDs and aliases below are literal fixture identities.
|
||||
State::open(
|
||||
context(optional),
|
||||
(0..1_000).rev().map(candidate).collect(),
|
||||
current,
|
||||
"SA · compatible functions",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Message {
|
||||
Chooser(Event),
|
||||
UnderPress,
|
||||
UnderRelease,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard(Option<String>);
|
||||
impl iced::advanced::Clipboard for Clipboard {
|
||||
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
|
||||
self.0.clone()
|
||||
}
|
||||
fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) {
|
||||
self.0 = Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
struct Mount {
|
||||
tree: Option<Tree>,
|
||||
size: Size,
|
||||
anchor: Rectangle,
|
||||
clipboard: Clipboard,
|
||||
}
|
||||
impl Default for Mount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tree: None,
|
||||
size: Size::new(1100., 700.),
|
||||
anchor: Rectangle {
|
||||
x: 790.,
|
||||
y: 640.,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
clipboard: Clipboard::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Mount {
|
||||
fn batch(
|
||||
&mut self,
|
||||
state: Option<&State>,
|
||||
events: &[(Input, Option<Point>)],
|
||||
) -> (Vec<Message>, Option<layout::Node>) {
|
||||
let underneath: Element<'_, Message, Theme, ()> =
|
||||
mouse_area(Space::new().width(iced::Fill).height(iced::Fill))
|
||||
.on_press(Message::UnderPress)
|
||||
.on_release(Message::UnderRelease)
|
||||
.into();
|
||||
let mut view: Element<'_, Message, Theme, ()> =
|
||||
widget::Popup::new(underneath, state, self.anchor, Message::Chooser).into();
|
||||
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
view.as_widget().diff(tree);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
|
||||
let viewport = Rectangle::with_size(self.size);
|
||||
let overlay_node = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
|
||||
let mut messages = Vec::new();
|
||||
for (event, point) in events {
|
||||
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if let Some(overlay_node) = &overlay_node {
|
||||
// Reconstruct the overlay between layout and every input, as
|
||||
// Iced does. Geometry must live in the measured layout tree.
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
|
||||
.unwrap();
|
||||
overlay.as_overlay_mut().update(
|
||||
event,
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
);
|
||||
overlay.as_overlay().draw(
|
||||
&mut (),
|
||||
&crate::style::theme(),
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: iced::Color::BLACK,
|
||||
},
|
||||
Layout::new(overlay_node),
|
||||
cursor,
|
||||
);
|
||||
if matches!(
|
||||
event,
|
||||
Input::Mouse(_) | Input::Keyboard(_) | Input::Touch(_) | Input::InputMethod(_)
|
||||
) {
|
||||
assert!(shell.is_event_captured(), "An open chooser owns input");
|
||||
}
|
||||
} else {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
&(),
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
(messages, overlay_node)
|
||||
}
|
||||
fn send(&mut self, state: &mut State, event: Input) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &[(event, None)]);
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Popup leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn type_text(&mut self, state: &mut State, text: &str) -> Vec<Outcome> {
|
||||
let (messages, _) = self.batch(Some(state), &typing(text));
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
Message::Chooser(event) => state.update(event),
|
||||
_ => panic!("Typing leaked input to its parent"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn geometry(&mut self, state: &State) -> widget::Geometry {
|
||||
let (_, node) = self.batch(Some(state), &[]);
|
||||
widget::Geometry::from_bounds(node.unwrap().children()[0].bounds())
|
||||
}
|
||||
fn click(&mut self, state: &State, point: Point) -> Vec<Message> {
|
||||
self.batch(
|
||||
Some(state),
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
),
|
||||
],
|
||||
)
|
||||
.0
|
||||
}
|
||||
}
|
||||
|
||||
fn named(key: keyboard::key::Named) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
// Iced 0.14's physical-key path intentionally inserts one scalar from text.
|
||||
// Whole-string typing is several key events; paste and IME commit are separate
|
||||
// input paths and are exercised below rather than faked as one physical key.
|
||||
fn typed(character: char) -> Input {
|
||||
let text = character.to_string();
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.clone().into()),
|
||||
modified_key: keyboard::Key::Character(text.clone().into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some(text.into()),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn typing(text: &str) -> Vec<(Input, Option<Point>)> {
|
||||
text.chars()
|
||||
.map(|character| (typed(character), None))
|
||||
.collect()
|
||||
}
|
||||
fn query(state: &mut State, value: &str) {
|
||||
let event = state.event(Action::Query {
|
||||
text: value.into(),
|
||||
serial: state.query_serial() + 1,
|
||||
});
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
assert_eq!(
|
||||
state.visible(6).map(|c| c.id).collect::<Vec<_>>(),
|
||||
(10_000..10_006).map(Id::from_u128).collect::<Vec<_>>()
|
||||
);
|
||||
query(&mut state, "control subsystem 6 @controller_0617");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_617));
|
||||
query(&mut state, "OA");
|
||||
assert_eq!(state.count(), 0);
|
||||
query(&mut state, &Id::from_u128(10_617).to_string());
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().alias, "controller_0617");
|
||||
|
||||
let placement = Candidate {
|
||||
id: Id::from_u128(20_001),
|
||||
name: "Control".into(),
|
||||
alias: "control_placement".into(),
|
||||
kind: "Placement".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Architecture / Main diagram / Control".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![placement],
|
||||
Current::Unassigned,
|
||||
"SA · placements",
|
||||
)
|
||||
.unwrap();
|
||||
query(&mut state, "placement main");
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(20_001));
|
||||
let event = state.event(Action::Choose(Id::from_u128(20_000)));
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Ignored,
|
||||
"Semantic ID is not the placement identity"
|
||||
);
|
||||
assert!(
|
||||
State::open(
|
||||
context(false),
|
||||
vec![candidate(1), candidate(1)],
|
||||
Current::Unassigned,
|
||||
"SA"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded() {
|
||||
let state = thousand(Current::InvalidRaw("not a uuid".into()), true);
|
||||
for size in [
|
||||
Size::new(1100., 700.),
|
||||
Size::new(1600., 1000.),
|
||||
Size::new(320., 320.),
|
||||
] {
|
||||
for y in [5., size.height / 2., size.height - 34.] {
|
||||
let mut mount = Mount {
|
||||
size,
|
||||
anchor: Rectangle {
|
||||
x: size.width - 300.,
|
||||
y,
|
||||
width: 278.,
|
||||
height: 32.,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let (_, node) = mount.batch(Some(&state), &[]);
|
||||
let node = node.unwrap();
|
||||
let bounds = node.children()[0].bounds();
|
||||
let geometry = widget::Geometry::from_bounds(bounds);
|
||||
assert!(bounds.x >= 8. && bounds.y >= 8.);
|
||||
assert!(bounds.x + bounds.width <= size.width - 8.);
|
||||
assert!(bounds.y + bounds.height <= size.height - 8.);
|
||||
assert!(bounds.width <= 420. && bounds.height <= 360.);
|
||||
assert!(geometry.rows > 0 && geometry.rows <= 6);
|
||||
assert_eq!(geometry.rows, if size.height == 320. { 4 } else { 6 });
|
||||
let search = node.children()[1].bounds();
|
||||
assert!(
|
||||
bounds.contains(search.position())
|
||||
&& bounds.contains(Point::new(
|
||||
search.x + search.width,
|
||||
search.y + search.height
|
||||
))
|
||||
);
|
||||
assert!(geometry.row(geometry.rows - 1).y + 34. <= geometry.detail.y);
|
||||
assert_eq!(state.visible(geometry.rows).count(), geometry.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let (messages, _) = mount.batch(Some(&state), &[(typed('a'), None), (typed('b'), None)]);
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
2,
|
||||
"Actual focused input must publish each edit"
|
||||
);
|
||||
let mut queries = Vec::new();
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Unexpected parent event")
|
||||
};
|
||||
let Action::Query { text, .. } = &event.action else {
|
||||
panic!("Typing must not choose a reference")
|
||||
};
|
||||
queries.push(text.clone());
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(queries, ["a", "ab"]);
|
||||
assert_eq!(state.query(), "ab");
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Home))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(mount.send(&mut state, typed('z')), [Outcome::Browsed]);
|
||||
assert_eq!(
|
||||
state.query(),
|
||||
"zab",
|
||||
"Rebuilding the controlled widget must not refocus at the end"
|
||||
);
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::PageDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.start(), 6);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_006));
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::ArrowDown)),
|
||||
[Outcome::Browsed]
|
||||
);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_007));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
(named(keyboard::key::Named::Enter), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages.into_iter().next().unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(event.action.writes_staged_value());
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_007))
|
||||
}
|
||||
);
|
||||
assert!(!state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent() {
|
||||
let mut state = thousand(Current::Missing(Id::from_u128(99)), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0731";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(state.count(), 1);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_731))
|
||||
}
|
||||
);
|
||||
assert_eq!(state.current, Current::Missing(Id::from_u128(99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&state).row(4).center();
|
||||
let captured = mount.click(&state, point);
|
||||
let Message::Chooser(old_choice) = captured[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
query(&mut state, "@controller_0700");
|
||||
assert_eq!(state.update(old_choice), Outcome::Ignored);
|
||||
// The rejected old Choose had marked the widget closing; a changed result
|
||||
// context must unlock it so an explicit current click can complete.
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
let messages = mount.click(&state, point);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::Chooser(event) = messages[0].clone() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(false),
|
||||
value: Some(Id::from_u128(10_700))
|
||||
}
|
||||
);
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let point = mount.geometry(&state).row(0).center();
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
query(&mut state, "@controller_0319");
|
||||
assert!(
|
||||
mount
|
||||
.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)]
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_choice_cannot_cross_document_revision_draft_record_or_array_path() {
|
||||
let source = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let point = mount.geometry(&source).row(0).center();
|
||||
let Message::Chooser(event) = mount.click(&source, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
for changed in 0..6 {
|
||||
let mut destination = context(false);
|
||||
match changed {
|
||||
0 => destination.document = Id::from_u128(999),
|
||||
1 => destination.revision += 1,
|
||||
2 => destination.draft = Id::from_u128(999),
|
||||
3 => destination.record = Id::from_u128(999),
|
||||
4 => destination.path = "/body/SomeList/members/1/scenario".into(),
|
||||
_ => {} // A new invocation even with every semantic ID unchanged.
|
||||
}
|
||||
let mut state = State::open(
|
||||
destination,
|
||||
vec![candidate(0)],
|
||||
Current::InvalidRaw("unfinished".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.update(event.clone()), Outcome::Ignored);
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
let mut invalidated = source.clone();
|
||||
invalidated.invalidate();
|
||||
assert_eq!(invalidated.update(event), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil() {
|
||||
for current in [
|
||||
Current::Missing(Id::from_u128(999)),
|
||||
Current::InvalidRaw("@incomplete/".into()),
|
||||
Current::Incompatible(candidate(999)),
|
||||
] {
|
||||
let mut state = thousand(current.clone(), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "no match exists";
|
||||
assert_eq!(
|
||||
mount.type_text(&mut state, query),
|
||||
vec![Outcome::Browsed; query.chars().count()]
|
||||
);
|
||||
assert_eq!(state.query(), query);
|
||||
assert_eq!(
|
||||
state.count(),
|
||||
0,
|
||||
"Verify actual input reached the empty-result state before Enter"
|
||||
);
|
||||
assert!(
|
||||
mount
|
||||
.send(&mut state, named(keyboard::key::Named::Enter))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.is_open());
|
||||
assert_eq!(state.current, current);
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Escape)),
|
||||
[Outcome::Closed(Close::Escape)]
|
||||
);
|
||||
assert_eq!(state.current, current);
|
||||
}
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
assert_eq!(
|
||||
mount.send(&mut state, named(keyboard::key::Named::Tab)),
|
||||
[Outcome::Closed(Close::Tab { backwards: false })]
|
||||
);
|
||||
let mut state = thousand(Current::InvalidRaw("?".into()), true);
|
||||
let point = mount.geometry(&state).clear.center();
|
||||
let Message::Chooser(event) = mount.click(&state, point).remove(0) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
state.update(event),
|
||||
Outcome::Write {
|
||||
context: context(true),
|
||||
value: None
|
||||
}
|
||||
);
|
||||
let mut required = thousand(Current::Unassigned, false);
|
||||
let clear = required.event(Action::Clear);
|
||||
assert_eq!(required.update(clear), Outcome::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
let outside = Point::new(20., 20.);
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside),
|
||||
)],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Expected only popup dismissal")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Closed(Close::Outside));
|
||||
let release = Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
|
||||
assert!(
|
||||
mount
|
||||
.batch(None, &[(release.clone(), Some(outside))])
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
mount
|
||||
.batch(
|
||||
None,
|
||||
&[
|
||||
(
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(outside)
|
||||
),
|
||||
(release, Some(outside)),
|
||||
]
|
||||
)
|
||||
.0,
|
||||
[Message::UnderPress, Message::UnderRelease]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write() {
|
||||
for path in [
|
||||
"/baseline",
|
||||
"/baseline/oa/value",
|
||||
"/baseline/sa/value/TypeRef/Named",
|
||||
] {
|
||||
assert!(
|
||||
Context::live(
|
||||
Id::from_u128(1),
|
||||
0,
|
||||
Id::from_u128(2),
|
||||
Id::from_u128(3),
|
||||
path,
|
||||
true
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut forged = context(true);
|
||||
forged.path = path.into();
|
||||
assert!(State::open(forged, vec![candidate(0)], Current::Unassigned, "History").is_err());
|
||||
}
|
||||
for action in [
|
||||
Action::Query {
|
||||
text: "Control".into(),
|
||||
serial: 1,
|
||||
},
|
||||
Action::Highlight(Id::from_u128(10_000)),
|
||||
Action::Move { delta: 1, rows: 6 },
|
||||
Action::Page { delta: 1, rows: 6 },
|
||||
Action::Close(Close::Escape),
|
||||
] {
|
||||
assert!(!action.writes_staged_value());
|
||||
}
|
||||
assert!(Action::Choose(Id::from_u128(10_000)).writes_staged_value());
|
||||
assert!(Action::Clear.writes_staged_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_query_serials_cannot_restore_obsolete_results() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old = state.event(Action::Query {
|
||||
text: "@controller_0001".into(),
|
||||
serial: 1,
|
||||
});
|
||||
let new = state.event(Action::Query {
|
||||
text: "@controller_0999".into(),
|
||||
serial: 2,
|
||||
});
|
||||
assert_eq!(state.update(new), Outcome::Browsed);
|
||||
assert_eq!(state.update(old), Outcome::Ignored);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order() {
|
||||
let mut state = thousand(Current::InvalidRaw("unfinished".into()), false);
|
||||
let mut mount = Mount::default();
|
||||
let query = "@controller_0720";
|
||||
let mut inputs = typing(query);
|
||||
inputs.push((named(keyboard::key::Named::Escape), None));
|
||||
let (messages, _) = mount.batch(Some(&state), &inputs);
|
||||
let outcomes: Vec<_> = messages
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
state.update(event)
|
||||
})
|
||||
.collect();
|
||||
let mut expected = vec![Outcome::Browsed; query.chars().count()];
|
||||
expected.push(Outcome::Closed(Close::Escape));
|
||||
assert_eq!(outcomes, expected);
|
||||
assert_eq!(state.query(), "@controller_0720");
|
||||
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
|
||||
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let old_absolute_row = state.event(Action::Choose(Id::from_u128(10_000)));
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::PageDown), None),
|
||||
(named(keyboard::key::Named::ArrowUp), None),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 3);
|
||||
for message in messages {
|
||||
let Message::Chooser(event) = message else {
|
||||
panic!("Input escaped the popup")
|
||||
};
|
||||
assert_eq!(state.update(event), Outcome::Browsed);
|
||||
}
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_011));
|
||||
assert_eq!(state.start(), 11);
|
||||
assert_eq!(state.update(old_absolute_row), Outcome::Ignored);
|
||||
assert!(state.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting() {
|
||||
let mut state = thousand(Current::Unassigned, false);
|
||||
let mut mount = Mount::default();
|
||||
mount.clipboard.0 = Some("@controller_0472".into());
|
||||
let paste = Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character("v".into()),
|
||||
modified_key: keyboard::Key::Character("v".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::COMMAND,
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::COMMAND,
|
||||
)),
|
||||
None,
|
||||
),
|
||||
(paste, None),
|
||||
(
|
||||
Input::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::empty(),
|
||||
)),
|
||||
None,
|
||||
),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("Paste should emit exactly one whole-string query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), "@controller_0472");
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_472));
|
||||
assert_eq!(state.current, Current::Unassigned);
|
||||
assert!(state.is_open());
|
||||
|
||||
let unicode_name = "Режим Δ負荷";
|
||||
let unicode = Candidate {
|
||||
id: Id::from_u128(30_111),
|
||||
name: unicode_name.into(),
|
||||
alias: "mode_unicode".into(),
|
||||
kind: "Function".into(),
|
||||
scope: "SA".into(),
|
||||
containment: "Система / 控制".into(),
|
||||
};
|
||||
let mut state = State::open(
|
||||
context(false),
|
||||
vec![unicode],
|
||||
Current::InvalidRaw("retained".into()),
|
||||
"SA",
|
||||
)
|
||||
.unwrap();
|
||||
use iced::advanced::input_method::Event as Ime;
|
||||
let preedit = [
|
||||
(Input::InputMethod(Ime::Opened), None),
|
||||
(
|
||||
Input::InputMethod(Ime::Preedit(unicode_name.into(), None)),
|
||||
None,
|
||||
),
|
||||
];
|
||||
assert!(
|
||||
mount.batch(Some(&state), &preedit).0.is_empty(),
|
||||
"Composition is not a committed query"
|
||||
);
|
||||
assert_eq!(state.query(), "");
|
||||
let (messages, _) = mount.batch(
|
||||
Some(&state),
|
||||
&[
|
||||
(Input::InputMethod(Ime::Preedit(String::new(), None)), None),
|
||||
(Input::InputMethod(Ime::Commit(unicode_name.into())), None),
|
||||
(Input::InputMethod(Ime::Closed), None),
|
||||
],
|
||||
);
|
||||
let [Message::Chooser(event)] = messages.as_slice() else {
|
||||
panic!("IME commit should emit one complete query")
|
||||
};
|
||||
assert_eq!(state.update(event.clone()), Outcome::Browsed);
|
||||
assert_eq!(state.query(), unicode_name);
|
||||
assert_eq!(state.count(), 1);
|
||||
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(30_111));
|
||||
assert_eq!(state.current, Current::InvalidRaw("retained".into()));
|
||||
assert!(state.is_open());
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Keyboard entry and focus operations scoped to one property form. Focus
|
||||
//! targets include the draft generation so delayed operations cannot focus a
|
||||
//! replacement array row or a recovered document with the same semantic IDs.
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Length, Rectangle, Size, Task, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Id, Operation, Tree, operation, tree},
|
||||
},
|
||||
keyboard, mouse,
|
||||
widget::container,
|
||||
};
|
||||
use oa_core::Id as ModelId;
|
||||
mod focus;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FormScope {
|
||||
pub session: ModelId,
|
||||
pub modal: bool,
|
||||
}
|
||||
pub(super) struct FormOwner(pub Option<FormScope>);
|
||||
const REVIEW_MODAL: &str = "reference-owning-review-modal";
|
||||
|
||||
/// Marks the entire Review dialog without changing its layout or event flow.
|
||||
pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Field {
|
||||
content: content.into(),
|
||||
id: Some(Id::new(REVIEW_MODAL)),
|
||||
on_open: None,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn field_id(generation: ModelId, path: &str) -> Id {
|
||||
Id::from(format!("reference:{generation}:{path}"))
|
||||
}
|
||||
pub fn scope_id(session: ModelId) -> Id {
|
||||
Id::from(format!("reference-form:{session}"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
focused: bool,
|
||||
pressed: bool,
|
||||
identity: Option<Id>,
|
||||
anchor: Option<Rectangle>,
|
||||
}
|
||||
impl operation::Focusable for Interaction {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.pressed = false;
|
||||
}
|
||||
}
|
||||
pub struct Field<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: renderer::Renderer,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
id: Option<Id>,
|
||||
on_open: Option<Box<dyn Fn(Rectangle) -> Message + 'a>>,
|
||||
}
|
||||
impl<'a, Message, R: renderer::Renderer> Field<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
id: Id,
|
||||
on_open: impl Fn(Rectangle) -> Message + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
id: Some(id),
|
||||
on_open: Some(Box::new(on_open)),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn scope<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
session: ModelId,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
container(Field {
|
||||
content: content.into(),
|
||||
id: None,
|
||||
on_open: None,
|
||||
})
|
||||
.id(scope_id(session))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn run<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
mut operation: Box<dyn Operation>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
loop {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut *operation);
|
||||
match operation.finish() {
|
||||
operation::Outcome::Chain(next) => {
|
||||
operation = next;
|
||||
changed = true;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(super) fn tab<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
scope: FormScope,
|
||||
backwards: bool,
|
||||
) -> bool {
|
||||
run(
|
||||
content,
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
Box::new(focus::relative(scope.session, backwards, scope.modal)),
|
||||
)
|
||||
}
|
||||
pub(super) fn own_input<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
) {
|
||||
content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, &mut focus::exclusive());
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Message, R> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction {
|
||||
identity: self.id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
if state.identity != self.id {
|
||||
*state = Interaction {
|
||||
identity: self.id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
if let Some(id) = &self.id
|
||||
&& self.on_open.is_some()
|
||||
{
|
||||
operation.focusable(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
operation.custom(
|
||||
Some(id),
|
||||
layout.bounds(),
|
||||
tree.state.downcast_mut::<Interaction>(),
|
||||
);
|
||||
}
|
||||
if self.on_open.is_none() {
|
||||
operation.container(self.id.as_ref(), layout.bounds());
|
||||
operation.traverse(&mut |operation| {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if let Some(on_open) = &self.on_open {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let visible = layout.bounds().intersection(viewport);
|
||||
let hovered = visible.is_some_and(|bounds| cursor.is_over(bounds));
|
||||
let activate = match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if hovered => {
|
||||
state.focused = true;
|
||||
state.pressed = true;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
false
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let activate = state.pressed && hovered;
|
||||
if state.pressed {
|
||||
shell.capture_event();
|
||||
}
|
||||
state.pressed = false;
|
||||
activate
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key:
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space),
|
||||
repeat: false,
|
||||
..
|
||||
}) if state.focused => true,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
state.focused = false;
|
||||
state.pressed = false;
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if activate && let Some(bounds) = state.anchor {
|
||||
shell.publish(on_open(bounds));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.on_open.is_some() {
|
||||
let focus = tree.state.downcast_ref::<Interaction>().focused;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
color: if focus || cursor.is_over(layout.bounds()) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
},
|
||||
width: if focus { 2. } else { 1. },
|
||||
radius: 5.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
}
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.on_open.is_some() && cursor.is_over(layout.bounds()) {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollable child updates use content coordinates. The root visits the
|
||||
/// retained tree before each input, so field anchors use current window
|
||||
/// coordinates even when a wheel event and activation share one runtime batch.
|
||||
pub(super) fn measure<Message, R: renderer::Renderer>(
|
||||
content: &mut Element<'_, Message, Theme, R>,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
viewport: Rectangle,
|
||||
) {
|
||||
content.as_widget_mut().operate(
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
&mut Measure {
|
||||
translation: Vector::ZERO,
|
||||
viewport: Some(viewport),
|
||||
pending: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
struct Measure {
|
||||
translation: Vector,
|
||||
viewport: Option<Rectangle>,
|
||||
pending: Option<(Vector, Option<Rectangle>)>,
|
||||
}
|
||||
impl Operation for Measure {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.viewport);
|
||||
if let Some((translation, viewport)) = self.pending.take() {
|
||||
self.translation = translation;
|
||||
self.viewport = viewport;
|
||||
}
|
||||
visit(self);
|
||||
(self.translation, self.viewport) = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
self.pending = Some((
|
||||
self.translation + translation,
|
||||
self.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds)),
|
||||
));
|
||||
}
|
||||
fn custom(&mut self, _: Option<&Id>, bounds: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(state) = state.downcast_mut::<Interaction>() {
|
||||
let bounds = Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
};
|
||||
state.anchor = self
|
||||
.viewport
|
||||
.and_then(|viewport| viewport.intersection(&bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Field<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(field: Field<'a, Message, R>) -> Self {
|
||||
Element::new(field)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_operation(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> impl Operation + 'static {
|
||||
focus::exact(session, generation, path, step)
|
||||
}
|
||||
pub fn focus<Message: Send + 'static>(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
step: i32,
|
||||
) -> Task<Message> {
|
||||
iced::advanced::widget::operate(focus_operation(session, generation, path, step)).discard()
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Focus movement and reveal share one root traversal. A form may be inside a
|
||||
//! scrollable; restricting traversal to its container would hide that ancestor.
|
||||
use super::{FormOwner, Id, ModelId, Operation, Rectangle, Vector, field_id, operation, scope_id};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
enum Origin {
|
||||
Current,
|
||||
Exact(Id),
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Field {
|
||||
id: Option<Id>,
|
||||
bounds: Rectangle,
|
||||
ancestors: Vec<Scroll>,
|
||||
}
|
||||
pub(super) struct Collect {
|
||||
scope: Id,
|
||||
origin: Origin,
|
||||
step: i32,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending_scroll: Option<Scroll>,
|
||||
scroll_index: usize,
|
||||
fields: Vec<Field>,
|
||||
focused: Option<usize>,
|
||||
outside_focused: bool,
|
||||
modal: bool,
|
||||
owner_valid: bool,
|
||||
}
|
||||
impl Collect {
|
||||
fn new(scope: Id, origin: Origin, step: i32) -> Self {
|
||||
let owner_valid = matches!(origin, Origin::Current);
|
||||
Self {
|
||||
scope,
|
||||
origin,
|
||||
step,
|
||||
inside: false,
|
||||
entering: None,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending_scroll: None,
|
||||
scroll_index: 0,
|
||||
fields: vec![],
|
||||
focused: None,
|
||||
outside_focused: false,
|
||||
modal: false,
|
||||
owner_valid,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn relative(session: ModelId, backwards: bool, modal: bool) -> Collect {
|
||||
Collect {
|
||||
modal,
|
||||
..Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Current,
|
||||
if backwards { -1 } else { 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
pub(super) fn exact(session: ModelId, generation: ModelId, path: &str, step: i32) -> Collect {
|
||||
Collect::new(
|
||||
scope_id(session),
|
||||
Origin::Exact(field_id(generation, path)),
|
||||
step,
|
||||
)
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.modal = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.inside, self.translation, self.scrolls.len());
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
if let Some(scroll) = self.pending_scroll.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous.0;
|
||||
self.translation = previous.1;
|
||||
self.scrolls.truncate(previous.2);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending_scroll = Some(Scroll {
|
||||
index: self.scroll_index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if !self.inside {
|
||||
self.outside_focused |= state.is_focused();
|
||||
return;
|
||||
}
|
||||
if state.is_focused() {
|
||||
self.focused = Some(self.fields.len());
|
||||
}
|
||||
self.fields.push(Field {
|
||||
id: id.cloned(),
|
||||
bounds: translated(bounds, self.translation),
|
||||
ancestors: self.scrolls.clone(),
|
||||
});
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.owner_valid || self.fields.is_empty() {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let origin = match &self.origin {
|
||||
Origin::Current if self.focused.is_none() && self.outside_focused && !self.modal => {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
Origin::Current => self
|
||||
.focused
|
||||
.map(|index| index as i64)
|
||||
.unwrap_or(if self.step < 0 {
|
||||
self.fields.len() as i64
|
||||
} else {
|
||||
-1
|
||||
}),
|
||||
Origin::Exact(id) => {
|
||||
let Some(index) = self
|
||||
.fields
|
||||
.iter()
|
||||
.position(|field| field.id.as_ref() == Some(id))
|
||||
else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
index as i64
|
||||
}
|
||||
};
|
||||
// A form keeps a reachable focus at both boundaries. Initial Tab enters
|
||||
// the first field; initial Shift+Tab enters the last field.
|
||||
let target = (origin + i64::from(self.step)).rem_euclid(self.fields.len() as i64) as usize;
|
||||
let field = &self.fields[target];
|
||||
let mut bounds = field.bounds;
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in field.ancestors.iter().rev() {
|
||||
let dx = reveal_axis(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
);
|
||||
let dy = reveal_axis(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
);
|
||||
// Iced reports rounded drawing offsets, while capacity can be
|
||||
// fractional. Clamping an already-visible axis can move it a pixel.
|
||||
let delta = Vector::new(
|
||||
if dx == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
|
||||
},
|
||||
if dy == 0. {
|
||||
0.
|
||||
} else {
|
||||
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
|
||||
},
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
scope: self.scope.clone(),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts,
|
||||
owner_valid: matches!(self.origin, Origin::Current),
|
||||
clear_outside: self.modal,
|
||||
preserve_inside: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal_axis(start: f32, length: f32, viewport_start: f32, viewport_length: f32) -> f32 {
|
||||
if start < viewport_start || length > viewport_length {
|
||||
start - viewport_start
|
||||
} else {
|
||||
(start + length - viewport_start - viewport_length).max(0.)
|
||||
}
|
||||
}
|
||||
pub(super) struct Apply {
|
||||
scope: Id,
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
target: usize,
|
||||
field_index: usize,
|
||||
scroll_index: usize,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
owner_valid: bool,
|
||||
clear_outside: bool,
|
||||
preserve_inside: bool,
|
||||
}
|
||||
pub(super) fn exclusive() -> Apply {
|
||||
Apply {
|
||||
scope: Id::new(super::REVIEW_MODAL),
|
||||
inside: false,
|
||||
entering: None,
|
||||
target: 0,
|
||||
field_index: 0,
|
||||
scroll_index: 0,
|
||||
shifts: BTreeMap::new(),
|
||||
owner_valid: true,
|
||||
clear_outside: true,
|
||||
preserve_inside: true,
|
||||
}
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<FormOwner>() {
|
||||
self.owner_valid = owner
|
||||
.0
|
||||
.is_some_and(|owner| scope_id(owner.session) == self.scope);
|
||||
self.clear_outside = owner.0.is_some_and(|owner| owner.modal);
|
||||
}
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, _: Rectangle) {
|
||||
self.entering = Some(self.inside || id == Some(&self.scope));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = self.inside;
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
visit(self);
|
||||
self.inside = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.owner_valid
|
||||
&& let Some(delta) = self.shifts.get(&self.scroll_index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.scroll_index += 1;
|
||||
}
|
||||
fn focusable(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.owner_valid && self.inside && !self.preserve_inside {
|
||||
if self.field_index == self.target {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
self.field_index += 1;
|
||||
} else if self.owner_valid && !self.inside && self.clear_outside {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
//! Root overlay adapter. Keep the wrapper mounted while closed so the release
|
||||
//! from a dismissal gesture cannot reach the underlying content. Parent modal
|
||||
//! ownership is supplied by the host (pass no chooser while a modal is active).
|
||||
use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser};
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
text::{self, Paragraph as _},
|
||||
widget::{Operation, Tree, tree},
|
||||
},
|
||||
alignment, keyboard, mouse, touch,
|
||||
widget::text_input,
|
||||
window,
|
||||
};
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
const ROW: f32 = 34.;
|
||||
const MARGIN: f32 = 8.;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Geometry {
|
||||
pub popup: Rectangle,
|
||||
pub search: Rectangle,
|
||||
pub current: Rectangle,
|
||||
pub detail: Rectangle,
|
||||
pub close: Rectangle,
|
||||
pub previous: Rectangle,
|
||||
pub next: Rectangle,
|
||||
pub clear: Rectangle,
|
||||
pub rows: usize,
|
||||
}
|
||||
impl Geometry {
|
||||
fn new(anchor: Rectangle, viewport: Size) -> Self {
|
||||
let width = anchor
|
||||
.width
|
||||
.clamp(278., 420.)
|
||||
.min((viewport.width - 2. * MARGIN).max(0.));
|
||||
let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.));
|
||||
let x = anchor.x.clamp(
|
||||
MARGIN.min(viewport.width / 2.),
|
||||
(viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)),
|
||||
);
|
||||
let below = anchor.y + anchor.height + 4.;
|
||||
let preferred = if below + height + MARGIN <= viewport.height {
|
||||
below
|
||||
} else {
|
||||
anchor.y - height - 4.
|
||||
};
|
||||
let y = preferred.clamp(
|
||||
MARGIN.min(viewport.height / 2.),
|
||||
(viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)),
|
||||
);
|
||||
Self::from_bounds(Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
pub(super) fn from_bounds(popup: Rectangle) -> Self {
|
||||
let inner = (popup.width - 16.).max(0.);
|
||||
let rect = |left, top, width, height| Rectangle {
|
||||
x: popup.x + left,
|
||||
y: popup.y + top,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let footer = (popup.height - 32.).max(0.);
|
||||
let detail_height = if popup.height >= 180. { 32. } else { 0. };
|
||||
let detail_top = (footer - detail_height - 4.).max(84.);
|
||||
let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS);
|
||||
Self {
|
||||
popup,
|
||||
search: rect(8., 28., inner, 30.),
|
||||
current: rect(8., 62., inner, 18.),
|
||||
detail: rect(8., detail_top, inner, detail_height),
|
||||
close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.),
|
||||
previous: rect(8., footer, 28., 24.),
|
||||
next: rect(40., footer, 28., 24.),
|
||||
clear: rect((popup.width - 84.).max(0.), footer, 76., 24.),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
pub fn row(&self, index: usize) -> Rectangle {
|
||||
Rectangle {
|
||||
x: self.popup.x + 8.,
|
||||
y: self.popup.y + 84. + index as f32 * ROW,
|
||||
width: (self.popup.width - 16.).max(0.),
|
||||
height: ROW,
|
||||
}
|
||||
}
|
||||
fn hit(&self, chooser: &Chooser, point: Point) -> Option<Action> {
|
||||
if self.close.contains(point) {
|
||||
return Some(Action::Close(Close::Escape));
|
||||
}
|
||||
if self.previous.contains(point) && chooser.start() > 0 {
|
||||
return Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.next.contains(point) && chooser.start() + self.rows < chooser.count() {
|
||||
return Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: self.rows,
|
||||
});
|
||||
}
|
||||
if self.clear.contains(point) && chooser.optional() {
|
||||
return Some(Action::Clear);
|
||||
}
|
||||
chooser
|
||||
.visible(self.rows)
|
||||
.enumerate()
|
||||
.find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Interaction {
|
||||
stamp: Option<Stamp>,
|
||||
serial: u64,
|
||||
pressed: Option<(ChoiceEvent, Pointer)>,
|
||||
closing: bool,
|
||||
swallow_mouse: Option<mouse::Button>,
|
||||
swallow_touch: Option<touch::Finger>,
|
||||
held_mouse: Option<mouse::Button>,
|
||||
held_touch: Option<touch::Finger>,
|
||||
detail_scroll: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pointer {
|
||||
Mouse,
|
||||
Touch(touch::Finger),
|
||||
}
|
||||
impl Interaction {
|
||||
fn swallow(&mut self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.swallow_mouse == Some(*button) =>
|
||||
{
|
||||
self.swallow_mouse = None;
|
||||
true
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.swallow_touch == Some(*id) => {
|
||||
self.swallow_touch = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup<'a, Message, R = iced::Renderer>
|
||||
where
|
||||
R: text::Renderer<Font = Font>,
|
||||
{
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
input: Element<'a, String, Theme, R>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: Rc<dyn Fn(ChoiceEvent) -> Message + 'a>,
|
||||
focus_scope: Option<super::trigger::FormScope>,
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> Popup<'a, Message, R> {
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
chooser: Option<&'a Chooser>,
|
||||
anchor: Rectangle,
|
||||
on_event: impl Fn(ChoiceEvent) -> Message + 'a,
|
||||
) -> Self {
|
||||
let chooser = chooser.filter(|state| state.is_open());
|
||||
let input = text_input(
|
||||
"Name, @alias, kind or containment",
|
||||
chooser.map_or("", Chooser::query),
|
||||
)
|
||||
.on_input(std::convert::identity)
|
||||
.size(12)
|
||||
.padding(6)
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
Self {
|
||||
content: content.into(),
|
||||
input,
|
||||
chooser,
|
||||
anchor,
|
||||
on_event: Rc::new(on_event),
|
||||
focus_scope: None,
|
||||
}
|
||||
}
|
||||
pub fn focus_scope(mut self, scope: Option<super::trigger::FormScope>) -> Self {
|
||||
self.focus_scope = scope;
|
||||
self
|
||||
}
|
||||
fn reconcile(&self, tree: &mut Tree) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
let stamp = self.chooser.map(Chooser::stamp);
|
||||
if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) {
|
||||
let input = tree.children[1]
|
||||
.state
|
||||
.downcast_mut::<text_input::State<R::Paragraph>>();
|
||||
if stamp.is_some() {
|
||||
input.focus();
|
||||
} else {
|
||||
input.unfocus();
|
||||
}
|
||||
state.closing = false;
|
||||
state.serial = self.chooser.map_or(0, Chooser::query_serial);
|
||||
}
|
||||
if state.stamp != stamp {
|
||||
state.pressed = None;
|
||||
// A queued Choose can be rejected after a preceding query changed
|
||||
// the result generation. Reconciliation must unlock this view.
|
||||
state.closing = false;
|
||||
state.detail_scroll = 0.;
|
||||
}
|
||||
state.stamp = stamp;
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> Widget<Message, Theme, R>
|
||||
for Popup<'_, Message, R>
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Interaction>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Interaction::default())
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![
|
||||
Tree::new(self.content.as_widget()),
|
||||
Tree::new(self.input.as_widget()),
|
||||
]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.children[0].diff(self.content.as_widget());
|
||||
tree.children[1].diff(self.input.as_widget());
|
||||
self.reconcile(tree);
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
|
||||
self.reconcile(tree);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
let mut owner =
|
||||
super::trigger::FormOwner(self.focus_scope.filter(|_| self.chooser.is_none()));
|
||||
operation.custom(None, layout.bounds(), &mut owner);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Interaction>();
|
||||
if state.swallow(event) || self.chooser.is_some() && is_input(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if is_input(event) && self.focus_scope.is_some_and(|scope| scope.modal) {
|
||||
super::trigger::own_input(&mut self.content, &mut tree.children[0], layout, renderer);
|
||||
}
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Tab),
|
||||
modifiers,
|
||||
..
|
||||
}) = event
|
||||
&& let Some(scope) = self.focus_scope
|
||||
&& super::trigger::tab(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
scope,
|
||||
modifiers.shift(),
|
||||
)
|
||||
{
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
super::trigger::measure(
|
||||
&mut self.content,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
*viewport,
|
||||
);
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
if self.chooser.is_some() {
|
||||
mouse::Cursor::Unavailable
|
||||
} else {
|
||||
cursor
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
if self.chooser.is_some() {
|
||||
mouse::Interaction::None
|
||||
} else {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
if let Some(chooser) = self.chooser {
|
||||
Some(overlay::Element::new(Box::new(ChooserOverlay {
|
||||
chooser,
|
||||
anchor: self.anchor,
|
||||
state: tree.state.downcast_mut(),
|
||||
input: &mut self.input,
|
||||
input_tree: &mut tree.children[1],
|
||||
on_event: &*self.on_event,
|
||||
})))
|
||||
} else {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> From<Popup<'a, Message, R>>
|
||||
for Element<'a, Message, Theme, R>
|
||||
{
|
||||
fn from(popup: Popup<'a, Message, R>) -> Self {
|
||||
Element::new(popup)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooserOverlay<'b, 'a, Message, R: text::Renderer<Font = Font>> {
|
||||
chooser: &'a Chooser,
|
||||
anchor: Rectangle,
|
||||
state: &'b mut Interaction,
|
||||
input: &'b mut Element<'a, String, Theme, R>,
|
||||
input_tree: &'b mut Tree,
|
||||
on_event: &'b dyn Fn(ChoiceEvent) -> Message,
|
||||
}
|
||||
impl<Message, R: text::Renderer<Font = Font>> ChooserOverlay<'_, '_, Message, R> {
|
||||
fn geometry(&self, layout: Layout<'_>) -> Geometry {
|
||||
Geometry::from_bounds(
|
||||
layout
|
||||
.children()
|
||||
.next()
|
||||
.expect("measured chooser bounds")
|
||||
.bounds(),
|
||||
)
|
||||
}
|
||||
fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) {
|
||||
if self.state.closing {
|
||||
return;
|
||||
}
|
||||
if action.writes_staged_value() || matches!(action, Action::Close(_)) {
|
||||
self.state.closing = true;
|
||||
self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse);
|
||||
self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch);
|
||||
}
|
||||
shell.publish((self.on_event)(self.chooser.event(action)));
|
||||
}
|
||||
fn press(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.publish(Action::Close(Close::Outside), shell);
|
||||
} else {
|
||||
self.state.pressed = geometry
|
||||
.hit(self.chooser, point)
|
||||
.map(|action| (self.chooser.event(action), pointer));
|
||||
}
|
||||
}
|
||||
fn release(
|
||||
&mut self,
|
||||
point: Point,
|
||||
pointer: Pointer,
|
||||
geometry: Geometry,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
if let Some((pressed, origin)) = self.state.pressed.take()
|
||||
&& origin == pointer
|
||||
&& pressed.stamp == self.chooser.stamp()
|
||||
&& geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action)
|
||||
{
|
||||
self.publish(pressed.action, shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, Theme, R>
|
||||
for ChooserOverlay<'_, '_, Message, R>
|
||||
{
|
||||
fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node {
|
||||
let geometry = Geometry::new(self.anchor, bounds);
|
||||
let input = self
|
||||
.input
|
||||
.as_widget_mut()
|
||||
.layout(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
&layout::Limits::new(Size::ZERO, geometry.search.size()),
|
||||
)
|
||||
.move_to(geometry.search.position());
|
||||
layout::Node::with_children(
|
||||
bounds,
|
||||
vec![
|
||||
layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()),
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) {
|
||||
self.input.as_widget_mut().operate(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
self.state.held_mouse = Some(*button)
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(button))
|
||||
if self.state.held_mouse == Some(*button) =>
|
||||
{
|
||||
self.state.held_mouse = None
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, .. }) => {
|
||||
self.state.held_touch = Some(*id)
|
||||
}
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) if self.state.held_touch == Some(*id) => self.state.held_touch = None,
|
||||
_ => {}
|
||||
}
|
||||
if self.state.swallow(event) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if self.state.closing {
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut handled = false;
|
||||
match event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
|
||||
use keyboard::key::Named;
|
||||
let action = match key {
|
||||
keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)),
|
||||
keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab {
|
||||
backwards: modifiers.shift(),
|
||||
})),
|
||||
keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageDown) => Some(Action::Page {
|
||||
delta: 1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::PageUp) => Some(Action::Page {
|
||||
delta: -1,
|
||||
rows: geometry.rows,
|
||||
}),
|
||||
keyboard::Key::Named(Named::Enter) => {
|
||||
handled = true;
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id))
|
||||
.map(|c| Action::Choose(c.id))
|
||||
}
|
||||
keyboard::Key::Character(c)
|
||||
if modifiers.control()
|
||||
&& modifiers.shift()
|
||||
&& c.eq_ignore_ascii_case("c") =>
|
||||
{
|
||||
clipboard.write(
|
||||
iced::advanced::clipboard::Kind::Standard,
|
||||
self.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail()),
|
||||
);
|
||||
handled = true;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
self.publish(action, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => {
|
||||
self.publish(Action::Close(Close::WindowChanged), shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
||||
if let Some(point) = cursor.position() {
|
||||
if !geometry.popup.contains(point) {
|
||||
self.state.swallow_mouse = Some(*button);
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
} else if *button == mouse::Button::Left && !geometry.search.contains(point) {
|
||||
self.press(point, Pointer::Mouse, geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
if let Some(point) = cursor.position() {
|
||||
self.release(point, Pointer::Mouse, geometry, shell);
|
||||
} else {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position })
|
||||
if self.state.pressed.is_none() =>
|
||||
{
|
||||
if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position)
|
||||
&& self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_none_or(|candidate| candidate.id != id)
|
||||
{
|
||||
self.publish(Action::Highlight(id), shell);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
|
||||
let (x, y) = match delta {
|
||||
mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.),
|
||||
mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
|
||||
};
|
||||
self.state.pressed = None;
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.detail.contains(p))
|
||||
{
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
let max = (measure::<R>(&detail, 11., renderer.default_font())
|
||||
- geometry.detail.width)
|
||||
.max(0.);
|
||||
self.state.detail_scroll =
|
||||
(self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max);
|
||||
shell.request_redraw();
|
||||
} else if y != 0. {
|
||||
self.publish(
|
||||
Action::Page {
|
||||
delta: if y < 0. { 1 } else { -1 },
|
||||
rows: geometry.rows,
|
||||
},
|
||||
shell,
|
||||
);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerPressed { id, position })
|
||||
if !geometry.search.contains(*position) =>
|
||||
{
|
||||
if !geometry.popup.contains(*position) {
|
||||
self.state.swallow_touch = Some(*id);
|
||||
}
|
||||
self.press(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLifted { id, position })
|
||||
if self.state.pressed.is_some() =>
|
||||
{
|
||||
self.release(*position, Pointer::Touch(*id), geometry, shell);
|
||||
handled = true;
|
||||
}
|
||||
Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => {
|
||||
self.state.pressed = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !handled {
|
||||
let mut messages = Vec::new();
|
||||
let mut input_shell = Shell::new(&mut messages);
|
||||
self.input.as_widget_mut().update(
|
||||
self.input_tree,
|
||||
event,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut input_shell,
|
||||
&geometry.popup,
|
||||
);
|
||||
let serial = Cell::new(self.state.serial);
|
||||
shell.merge(input_shell, |text| {
|
||||
serial.set(serial.get().saturating_add(1));
|
||||
(self.on_event)(self.chooser.event(Action::Query {
|
||||
text,
|
||||
serial: serial.get(),
|
||||
}))
|
||||
});
|
||||
self.state.serial = serial.get();
|
||||
}
|
||||
if is_input(event) {
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
renderer_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let geometry = self.geometry(layout);
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: geometry.popup,
|
||||
border: Border {
|
||||
color: style::LINE,
|
||||
width: 1.,
|
||||
radius: 8.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Color::WHITE,
|
||||
);
|
||||
renderer.with_layer(geometry.popup, |renderer| {
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.scope_label,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.popup.x + 8.,
|
||||
y: geometry.popup.y + 4.,
|
||||
width: (geometry.popup.width - 78.).max(0.),
|
||||
height: 22.,
|
||||
},
|
||||
);
|
||||
label(renderer, "Close", 11., style::TEAL, geometry.close);
|
||||
label(
|
||||
renderer,
|
||||
&self.chooser.current.label(),
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.current,
|
||||
);
|
||||
for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() {
|
||||
let row = geometry.row(i);
|
||||
if self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.is_some_and(|c| c.id == candidate.id)
|
||||
{
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: row,
|
||||
..Default::default()
|
||||
},
|
||||
Color::from_rgb8(218, 238, 235),
|
||||
);
|
||||
}
|
||||
let alias_width = (row.width * 0.42).min(
|
||||
measure::<R>(
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
renderer.default_font(),
|
||||
) + 4.,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.name,
|
||||
12.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
width: (row.width - alias_width - 6.).max(0.),
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&format!("@{}", candidate.alias),
|
||||
11.,
|
||||
style::INK,
|
||||
Rectangle {
|
||||
x: row.x + row.width - alias_width,
|
||||
width: alias_width,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
&candidate.context_label(),
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: row.y + 17.,
|
||||
height: 17.,
|
||||
..row
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.chooser.count() == 0 {
|
||||
label(
|
||||
renderer,
|
||||
"No compatible matches. Clear the query.",
|
||||
11.,
|
||||
style::MUTED,
|
||||
geometry.row(0),
|
||||
);
|
||||
}
|
||||
let detail = self
|
||||
.chooser
|
||||
.highlighted()
|
||||
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
|
||||
renderer.with_layer(geometry.detail, |renderer| {
|
||||
raw_label(
|
||||
renderer,
|
||||
detail,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.detail.x - self.state.detail_scroll,
|
||||
width: geometry.detail.width + self.state.detail_scroll,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"Scroll details · Ctrl+Shift+C copies",
|
||||
10.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
y: geometry.detail.y + 16.,
|
||||
height: 16.,
|
||||
..geometry.detail
|
||||
},
|
||||
);
|
||||
});
|
||||
label(
|
||||
renderer,
|
||||
"‹",
|
||||
15.,
|
||||
if self.chooser.start() > 0 {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.previous,
|
||||
);
|
||||
label(
|
||||
renderer,
|
||||
"›",
|
||||
15.,
|
||||
if self.chooser.start() + geometry.rows < self.chooser.count() {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
},
|
||||
geometry.next,
|
||||
);
|
||||
let count = if self.chooser.count() == 0 {
|
||||
"0 results".into()
|
||||
} else {
|
||||
format!(
|
||||
"{}–{} / {}",
|
||||
self.chooser.start() + 1,
|
||||
(self.chooser.start() + geometry.rows).min(self.chooser.count()),
|
||||
self.chooser.count()
|
||||
)
|
||||
};
|
||||
label(
|
||||
renderer,
|
||||
&count,
|
||||
11.,
|
||||
style::MUTED,
|
||||
Rectangle {
|
||||
x: geometry.next.x + 30.,
|
||||
width: (geometry.clear.x - geometry.next.x - 32.).max(0.),
|
||||
..geometry.next
|
||||
},
|
||||
);
|
||||
if self.chooser.optional() {
|
||||
label(
|
||||
renderer,
|
||||
"Clear reference",
|
||||
10.,
|
||||
style::TEAL,
|
||||
geometry.clear,
|
||||
);
|
||||
}
|
||||
self.input.as_widget().draw(
|
||||
self.input_tree,
|
||||
renderer,
|
||||
theme,
|
||||
renderer_style,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
);
|
||||
});
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
let geometry = self.geometry(layout);
|
||||
if cursor
|
||||
.position()
|
||||
.is_some_and(|p| geometry.hit(self.chooser, p).is_some())
|
||||
{
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
self.input_tree,
|
||||
layout.children().nth(1).unwrap(),
|
||||
cursor,
|
||||
&geometry.popup,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_input(event: &Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_)
|
||||
)
|
||||
}
|
||||
fn measure<R: text::Renderer<Font = Font>>(content: &str, size: f32, font: Font) -> f32 {
|
||||
R::Paragraph::with_text(text::Text {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font,
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
})
|
||||
.min_width()
|
||||
}
|
||||
fn label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: &str,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
let font = renderer.default_font();
|
||||
let mut display = content.to_owned();
|
||||
if measure::<R>(content, size, font) > bounds.width {
|
||||
let ends: Vec<_> = content
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(content.len()))
|
||||
.collect();
|
||||
let (mut low, mut high) = (0, ends.len());
|
||||
while low + 1 < high {
|
||||
let middle = (low + high) / 2;
|
||||
if measure::<R>(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width
|
||||
{
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
display = format!("{}…", &content[..ends[low]]);
|
||||
}
|
||||
raw_label(renderer, display, size, color, bounds);
|
||||
}
|
||||
fn raw_label<R: text::Renderer<Font = Font>>(
|
||||
renderer: &mut R,
|
||||
content: String,
|
||||
size: f32,
|
||||
color: Color,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content,
|
||||
bounds: bounds.size(),
|
||||
size: size.into(),
|
||||
line_height: text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Left,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::None,
|
||||
},
|
||||
Point::new(bounds.x, bounds.center_y()),
|
||||
color,
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user