Add scoped overlay hosts

This commit is contained in:
iris committed 2026-09-10 18:49:03 -04:00
1 parent b53a17c436
commit 599d33287c
24 files changed
+910 -60

No files matched your search

+1 -1
View File
@@ -93,7 +93,7 @@ impl DefaultAppState for Client {
.pad(dp(16)) .pad(dp(16))
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
ui_state.set_root(text); ui_state.set_root(rsc, text);
None None
} }
}; };
+1 -1
View File
@@ -142,7 +142,7 @@ impl AndroidAppState for BenchClient {
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
ui_state.set_root(tree); ui_state.set_root(rsc, tree);
let font = rsc.ui.text.font_diagnostics(); let font = rsc.ui.text.font_diagnostics();
log::info!( log::info!(
+1 -1
View File
@@ -99,7 +99,7 @@ impl AndroidAppState for TranscriptClient {
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
ui_state.set_root(tree); ui_state.set_root(rsc, tree);
let mut client = Self { let mut client = Self {
ui_state, ui_state,
+5 -2
View File
@@ -99,12 +99,15 @@ where
)) ))
} }
pub fn open<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Result<Opened, String> pub fn open<Rsc: HasEvents>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot<Rsc>,
) -> Result<Opened, String>
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let (opened, tree) = build_screen(rsc)?; let (opened, tree) = build_screen(rsc)?;
ui_state.set_root(tree); ui_state.set_root(rsc, tree);
Ok(opened) Ok(opened)
} }
+5 -6
View File
@@ -224,14 +224,14 @@ impl TranscriptScreen {
pub fn build<Rsc: HasEvents>( pub fn build<Rsc: HasEvents>(
rsc: &mut Rsc, rsc: &mut Rsc,
ui_state: &mut impl HasRoot, ui_state: &mut impl HasRoot<Rsc>,
rows: Vec<FoldedRow>, rows: Vec<FoldedRow>,
) -> TranscriptScreen ) -> TranscriptScreen
where where
Rsc::State: FocusHost + OpenUrl, Rsc::State: FocusHost + OpenUrl,
{ {
let (screen, tree) = build_tree(rsc, rows); let (screen, tree) = build_tree(rsc, rows);
ui_state.set_root(tree); ui_state.set_root(rsc, tree);
screen screen
} }
@@ -281,10 +281,9 @@ where
{ {
list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| { list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data; let input = &ctx.data;
rsc.with_nearest_controller::<SelectionController, _>( rsc.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
list, selection.drag(id, rsc, input)
|id, selection, rsc| selection.drag(id, rsc, input), });
);
}) })
.add(rsc); .add(rsc);
} }
+3 -4
View File
@@ -214,10 +214,9 @@ where
let (pos, size) = (ctx.data.pos, ctx.data.size); let (pos, size) = (ctx.data.pos, ctx.data.size);
let input = &ctx.data; let input = &ctx.data;
let outcome = rsc let outcome = rsc
.with_nearest_controller::<SelectionController, _>( .with_nearest_controller::<SelectionController, _>(field, |id, selection, rsc| {
field, selection.drag(id, rsc, input)
|id, selection, rsc| selection.drag(id, rsc, input), })
)
.unwrap_or(SelectionInput::Tapped); .unwrap_or(SelectionInput::Tapped);
// A *tap*, decided by the same `DragArbiter` the pan and // A *tap*, decided by the same `DragArbiter` the pan and
// the selection are: a gesture that panned the list past // the selection are: a gesture that panned the list past
+3 -4
View File
@@ -11,10 +11,9 @@ pub(crate) fn on_tap<Rsc: HasEvents>(
ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| { ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data; let input = &ctx.data;
let outcome = rsc let outcome = rsc
.with_nearest_controller::<SelectionController, _>( .with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
list, selection.drag(id, rsc, input)
|id, selection, rsc| selection.drag(id, rsc, input), })
)
.unwrap_or(SelectionInput::Tapped); .unwrap_or(SelectionInput::Tapped);
if outcome == SelectionInput::Tapped { if outcome == SelectionInput::Tapped {
f(rsc); f(rsc);
+1 -1
View File
@@ -14,7 +14,7 @@ fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(&mut h.rsc) .add_strong(&mut h.rsc)
.any(); .any();
h.state.set_root(root); h.state.set_root(&mut h.rsc, root);
h.frame(0); h.frame(0);
h.frame(PHONE_FRAME_MS); h.frame(PHONE_FRAME_MS);
(h, opened.screen) (h, opened.screen)
-5
View File
@@ -30,11 +30,6 @@ Framework capabilities needed by `RUST.md`'s port plan:
range. The shared `TextSelection` engine already computes the same geometry range. The shared `TextSelection` engine already computes the same geometry
for selection highlights; expose one shared primitive rather than giving for selection highlights; expose one shared primitive rather than giving
the app a second text-layout path. the app a second text-layout path.
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
**P5**.) Needed for the session settings dialog, `UsageDialog`'s
equivalent, and the delete-with-`deleteForeign` confirmation with its
toggle switch. Build once, wherever it is first needed, rather than
once per screen that wants one.
- [ ] **A horizontal gauge/bar widget.** (**P1**.) For - [ ] **A horizontal gauge/bar widget.** (**P1**.) For
`SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction, `SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction,
nothing fancier. nothing fancier.
+8 -8
View File
@@ -678,8 +678,8 @@ it is both. This is an `app-rust` defect, not an iris framework item.
`AI_SANDBOX_BIG_MB` and `--delay`. `AI_SANDBOX_BIG_MB` and `--delay`.
- [ ] **P1d — images, the session settings dialog, attachments, - [ ] **P1d — images, the session settings dialog, attachments,
usage bar.** `SessionImage` thumbnails (the scaled image usage bar.** `SessionImage` thumbnails (the scaled image
widget), the modal primitive and `SessionSettingsDialog`/ widget), the existing overlay facility and
`UsageDialog`, `PendingAttachments` over the attachments `SessionSettingsDialog`/`UsageDialog`, `PendingAttachments` over the attachments
route (`api.rs` gap), `SessionUsageBar` (the gauge widget). route (`api.rs` gap), `SessionUsageBar` (the gauge widget).
- [ ] **P1e — keyboard and insets behaviours** from AGENTS.md's - [ ] **P1e — keyboard and insets behaviours** from AGENTS.md's
"Things that have bitten", re-verified on the phone build: "Things that have bitten", re-verified on the phone build:
@@ -715,11 +715,11 @@ it is both. This is an `app-rust` defect, not an iris framework item.
**iris widgets missing, → `IRIS_TODO.md`'s "Build (for the port)" **iris widgets missing, → `IRIS_TODO.md`'s "Build (for the port)"
section**: the distance-to-unloaded-edge query P1c needs for its section**: the distance-to-unloaded-edge query P1c needs for its
viewport-sized history cushion; per-range text backgrounds for inline viewport-sized history cushion; per-range text backgrounds for inline
code; a fitted image widget; one modal/dialog primitive reused by the code; a fitted image widget; and a horizontal gauge for
settings and usage dialogs; and a horizontal gauge for `SessionUsageBar`. The settings and usage dialogs can use Iris's
`SessionUsageBar`. Tappable links already exist. Row accessibility existing local overlay hosts. Tappable links already exist. Row
names are app content applied through iris's existing `.label()` API, accessibility names are app content applied through iris's existing
not a missing framework widget. `.label()` API, not a missing framework widget.
**Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by **Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by
`ui-trace record --do "tap '<label>'"` — a session with the big `ui-trace record --do "tap '<label>'"` — a session with the big
@@ -790,7 +790,7 @@ it is both. This is an `app-rust` defect, not an iris framework item.
dimmed, drained of colour and labelled with the operation in progress dimmed, drained of colour and labelled with the operation in progress
without blocking the list's scroll — so it belongs in `src/ui`, not without blocking the list's scroll — so it belongs in `src/ui`, not
iris. `uniqueItems` is app logic there too. Iris itself still needs iris. `uniqueItems` is app logic there too. Iris itself still needs
the modal primitive P1 flagged and a toggle switch for the a toggle switch for the
delete-with-`deleteForeign` flow. delete-with-`deleteForeign` flow.
**Pass condition**: `ui-trace` tap-by-name on all four tabs against **Pass condition**: `ui-trace` tap-by-name on all four tabs against
+30
View File
@@ -14,6 +14,10 @@ impl ControllerId {
pub fn host(self) -> WidgetId { pub fn host(self) -> WidgetId {
self.host self.host
} }
pub fn is<C: 'static>(self) -> bool {
self.kind == TypeId::of::<C>()
}
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -44,6 +48,10 @@ pub trait Controller<Rsc>: ControllerValue {
fn command(&mut self, _command: Command, _rsc: &mut Rsc) -> CommandResult { fn command(&mut self, _command: Command, _rsc: &mut Rsc) -> CommandResult {
CommandResult::Unused CommandResult::Unused
} }
fn blocks_prior_input(&self) -> bool {
false
}
} }
pub struct ControllerManager<Rsc> { pub struct ControllerManager<Rsc> {
@@ -206,6 +214,16 @@ impl<Rsc: 'static> ControllerManager<Rsc> {
self.command_target self.command_target
} }
pub fn command_target_blocks_input(&self) -> bool {
let Some(id) = self.command_target else {
return false;
};
self.by_widget
.get(&id.host)
.and_then(|controllers| controllers.get(&id.kind))
.is_some_and(|controller| controller.blocks_prior_input())
}
pub(crate) fn command_target_revision(&self) -> u64 { pub(crate) fn command_target_revision(&self) -> u64 {
self.command_target_revision self.command_target_revision
} }
@@ -214,6 +232,18 @@ impl<Rsc: 'static> ControllerManager<Rsc> {
self.command_boundary self.command_boundary
} }
pub(crate) fn is_below(&self, mut widget: WidgetId, ancestor: WidgetId) -> bool {
loop {
let Some(parent) = self.parents.get(&widget).copied().flatten() else {
return false;
};
if parent == ancestor {
return true;
}
widget = parent;
}
}
pub(crate) fn set_command_boundary(&mut self, boundary: Option<WidgetId>) { pub(crate) fn set_command_boundary(&mut self, boundary: Option<WidgetId>) {
self.command_boundary = boundary; self.command_boundary = boundary;
} }
+6 -7
View File
@@ -54,13 +54,12 @@ pub trait HasEvents: Sized + UiRsc + HasState {
fn run_command(&mut self, command: Command) -> CommandResult { fn run_command(&mut self, command: Command) -> CommandResult {
let revision = self.events().controllers.command_target_revision(); let revision = self.events().controllers.command_target_revision();
if self if let Some(boundary) = self.events().controllers.command_boundary()
.events() && self
.controllers .events()
.command_target() .controllers
.is_some_and(|target| { .command_target()
self.events().controllers.command_boundary() == Some(target.host()) .is_none_or(|target| !self.events().controllers.is_below(target.host(), boundary))
})
{ {
return CommandResult::Unused; return CommandResult::Unused;
} }
+4 -4
View File
@@ -22,14 +22,14 @@ pub trait WidgetLike<Rsc: UiRsc, Tag>: Sized {
} }
} }
fn set_root(self, rsc: &mut Rsc, root: &mut impl HasRoot) { fn set_root(self, rsc: &mut Rsc, root: &mut impl HasRoot<Rsc>) {
let id = self.add_strong(rsc); let id = self.add_strong(rsc);
root.set_root(id); root.set_root(rsc, id);
} }
} }
pub trait HasRoot { pub trait HasRoot<Rsc> {
fn set_root(&mut self, root: StrongWidget); fn set_root(&mut self, rsc: &mut Rsc, root: StrongWidget);
} }
pub trait WidgetArrLike<Rsc, const LEN: usize, Tag> { pub trait WidgetArrLike<Rsc, const LEN: usize, Tag> {
+1 -1
View File
@@ -31,7 +31,7 @@ impl DefaultAppState for State {
.ui .ui
.widgets .widgets
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End)); .add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End));
ui_state.set_root(root.any()); ui_state.set_root(rsc, root.any());
Self { Self {
ui_state, ui_state,
span: span_weak, span: span_weak,
+1 -1
View File
@@ -79,7 +79,7 @@ impl DefaultAppState for State {
.masked() .masked()
.background(rect(PaintId::WHITE)) .background(rect(PaintId::WHITE))
.add_strong(rsc); .add_strong(rsc);
ui_state.set_root(root.any()); ui_state.set_root(rsc, root.any());
Self { ui_state } Self { ui_state }
} }
+3 -1
View File
@@ -31,7 +31,9 @@ fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
impl<State: AndroidAppState> IrisViewPeer<State> { impl<State: AndroidAppState> IrisViewPeer<State> {
fn focus(&self) -> Option<WeakWidget<TextEdit>> { fn focus(&self) -> Option<WeakWidget<TextEdit>> {
self.state.android_state().focus (!self.rsc.events.controllers.command_target_blocks_input())
.then_some(self.state.android_state().focus)
.flatten()
} }
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) { pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
+10 -3
View File
@@ -109,9 +109,9 @@ impl AndroidUiState {
} }
} }
impl HasRoot for AndroidUiState { impl<State: 'static> HasRoot<AndroidRsc<State>> for AndroidUiState {
fn set_root(&mut self, root: StrongWidget) { fn set_root(&mut self, rsc: &mut AndroidRsc<State>, root: StrongWidget) {
self.root = Some(root); self.root = Some(crate::overlay::default_overlay_root(rsc, root));
} }
} }
@@ -463,12 +463,19 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// `android/insets.rs`'s doc comment for why insets could not take // `android/insets.rs`'s doc comment for why insets could not take
// the same shortcut. // the same shortcut.
if key_code == Keycode::Back { if key_code == Keycode::Back {
if self.rsc.run_command(Command::Escape) != CommandResult::Unused {
self.after_input(ctx);
return true;
}
let handled = self.state.back_pressed(&mut self.rsc, &mut self.render); let handled = self.state.back_pressed(&mut self.rsc, &mut self.render);
if handled { if handled {
self.after_input(ctx); self.after_input(ctx);
} }
return handled; return handled;
} }
if self.rsc.events.controllers.command_target_blocks_input() {
return true;
}
let handled = super::input::on_key( let handled = super::input::on_key(
&mut self.rsc, &mut self.rsc,
&mut self.state, &mut self.state,
+7 -4
View File
@@ -65,9 +65,9 @@ pub struct DefaultUiState {
pub access: AccessTree, pub access: AccessTree,
} }
impl HasRoot for DefaultUiState { impl<State: 'static> HasRoot<DefaultRsc<State>> for DefaultUiState {
fn set_root(&mut self, root: StrongWidget) { fn set_root(&mut self, rsc: &mut DefaultRsc<State>, root: StrongWidget) {
self.root = Some(root); self.root = Some(crate::overlay::default_overlay_root(rsc, root));
} }
} }
@@ -371,6 +371,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
CommandResult::Unused => false, CommandResult::Unused => false,
}; };
if !command_used if !command_used
&& !rsc.events.controllers.command_target_blocks_input()
&& let Some(sel) = ui_state.focus && let Some(sel) = ui_state.focus
&& event.state.is_pressed() && event.state.is_pressed()
{ {
@@ -402,7 +403,9 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
} }
} }
WindowEvent::Ime(ime) => { WindowEvent::Ime(ime) => {
if let Some(sel) = ui_state.focus { if !rsc.events.controllers.command_target_blocks_input()
&& let Some(sel) = ui_state.focus
{
let mut text = sel.edit(rsc); let mut text = sel.edit(rsc);
match ime { match ime {
Ime::Enabled | Ime::Disabled => (), Ime::Enabled | Ime::Disabled => (),
+3 -3
View File
@@ -135,9 +135,9 @@ impl HarnessState {
} }
} }
impl HasRoot for HarnessState { impl HasRoot<HarnessRsc> for HarnessState {
fn set_root(&mut self, root: StrongWidget) { fn set_root(&mut self, rsc: &mut HarnessRsc, root: StrongWidget) {
self.root = Some(root); self.root = Some(crate::overlay::default_overlay_root(rsc, root));
} }
} }
+2
View File
@@ -16,6 +16,7 @@ pub mod attr;
pub mod diagnostics; pub mod diagnostics;
pub mod event; pub mod event;
pub mod harness; pub mod harness;
pub mod overlay;
pub mod platform; pub mod platform;
pub mod sense; pub mod sense;
pub mod state; pub mod state;
@@ -43,6 +44,7 @@ pub mod prelude {
pub use event::*; pub use event::*;
pub use iris_core::*; pub use iris_core::*;
pub use iris_macro::*; pub use iris_macro::*;
pub use overlay::*;
pub use platform::*; pub use platform::*;
pub use sense::*; pub use sense::*;
pub use state::*; pub use state::*;
+812
View File
@@ -0,0 +1,812 @@
//! Optional overlays over an ordinary [`Stack`].
//!
//! An overlay host owns independent single and stackable controllers. Opening
//! any overlay removes the active single on each host the request traverses;
//! stackables remain in opening order, and the host's optional single is
//! always its final child. Controller lookup follows the active parent map
//! maintained by `EventManager`, so callers need only the requesting widget,
//! not access to `UiRenderState`.
use crate::prelude::*;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OverlayKind {
Single,
Stackable,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CancelBehavior {
Consume,
PassThrough,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OverlayOptions {
pub kind: OverlayKind,
pub cancel: CancelBehavior,
pub blocks_prior_input: bool,
}
impl OverlayOptions {
pub const fn single(cancel: CancelBehavior) -> Self {
Self {
kind: OverlayKind::Single,
cancel,
blocks_prior_input: false,
}
}
pub const fn stackable(cancel: CancelBehavior) -> Self {
Self {
kind: OverlayKind::Stackable,
cancel,
blocks_prior_input: false,
}
}
pub const fn blocking(mut self) -> Self {
self.blocks_prior_input = true;
self
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OverlayHostOptions {
pub single: bool,
pub stackable: bool,
}
impl OverlayHostOptions {
pub const BOTH: Self = Self {
single: true,
stackable: true,
};
pub const SINGLE: Self = Self {
single: true,
stackable: false,
};
pub const STACKABLE: Self = Self {
single: false,
stackable: true,
};
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OverlayHandle {
host: WidgetId,
kind: OverlayKind,
serial: u64,
}
impl OverlayHandle {
pub fn close<Rsc: HasEvents>(self, rsc: &mut Rsc) -> bool {
match self.kind {
OverlayKind::Single => {
let Some(id) = rsc
.events()
.controllers
.id::<SingleOverlayController>(self.host)
else {
return false;
};
rsc.with_controller(id, |controller: &mut SingleOverlayController, rsc| {
controller.close(id, self.serial, rsc)
})
.unwrap_or(false)
}
OverlayKind::Stackable => {
let Some(id) = rsc
.events()
.controllers
.id::<StackableOverlayController>(self.host)
else {
return false;
};
rsc.with_controller(id, |controller: &mut StackableOverlayController, rsc| {
controller.close(id, self.serial, rsc)
})
.unwrap_or(false)
}
}
}
}
struct OverlayEntry {
serial: u64,
widget: WidgetId,
previous_target: Option<ControllerId>,
cancel: CancelBehavior,
blocks_prior_input: bool,
}
pub struct SingleOverlayController {
stack: WeakWidget<Stack>,
active: Option<OverlayEntry>,
next_serial: u64,
}
impl SingleOverlayController {
fn new(stack: WeakWidget<Stack>) -> Self {
Self {
stack,
active: None,
next_serial: 1,
}
}
fn open<Rsc: HasEvents>(
&mut self,
id: ControllerId,
popup: StrongWidget,
cancel: CancelBehavior,
blocks_prior_input: bool,
rsc: &mut Rsc,
) -> OverlayHandle {
self.dismiss(id, rsc, true);
let serial = self.take_serial();
let widget = popup.id();
let previous_target = rsc.events().controllers.command_target();
(self.stack)(rsc).children.push(popup);
self.active = Some(OverlayEntry {
serial,
widget,
previous_target,
cancel,
blocks_prior_input,
});
rsc.set_command_target(Some(id));
OverlayHandle {
host: id.host(),
kind: OverlayKind::Single,
serial,
}
}
fn close<Rsc: HasEvents>(&mut self, id: ControllerId, serial: u64, rsc: &mut Rsc) -> bool {
if self.active.as_ref().map(|entry| entry.serial) != Some(serial) {
return false;
}
self.dismiss(id, rsc, false);
true
}
fn dismiss<Rsc: HasEvents>(
&mut self,
id: ControllerId,
rsc: &mut Rsc,
replacement: bool,
) -> bool {
let Some(entry) = self.active.take() else {
return false;
};
let current_target = rsc.events().controllers.command_target();
let was_target = current_target == Some(id);
remove_child(self.stack, entry.widget, rsc);
if was_target {
rsc.set_command_target(entry.previous_target);
}
if replacement && entry.cancel == CancelBehavior::PassThrough {
rsc.set_command_target(entry.previous_target);
let _ = rsc.run_command_before(Command::Escape, self.stack);
if !was_target {
rsc.set_command_target(current_target);
}
}
true
}
fn take_serial(&mut self) -> u64 {
let serial = self.next_serial;
self.next_serial = self.next_serial.wrapping_add(1).max(1);
serial
}
}
impl<Rsc: HasEvents> Controller<Rsc> for SingleOverlayController {
fn command(&mut self, command: Command, rsc: &mut Rsc) -> CommandResult {
if command != Command::Escape {
return CommandResult::Unused;
}
let Some(entry) = self.active.take() else {
return CommandResult::Unused;
};
remove_child(self.stack, entry.widget, rsc);
rsc.set_command_target(entry.previous_target);
if entry.cancel == CancelBehavior::PassThrough {
let _ = rsc.run_command(command);
}
CommandResult::Used
}
fn blocks_prior_input(&self) -> bool {
self.active
.as_ref()
.is_some_and(|entry| entry.blocks_prior_input)
}
}
pub struct StackableOverlayController {
stack: WeakWidget<Stack>,
entries: Vec<OverlayEntry>,
next_serial: u64,
}
impl StackableOverlayController {
fn new(stack: WeakWidget<Stack>) -> Self {
Self {
stack,
entries: Vec::new(),
next_serial: 1,
}
}
fn open<Rsc: HasEvents>(
&mut self,
id: ControllerId,
overlay: StrongWidget,
cancel: CancelBehavior,
blocks_prior_input: bool,
rsc: &mut Rsc,
) -> OverlayHandle {
let serial = self.take_serial();
let widget = overlay.id();
let previous_target = rsc.events().controllers.command_target();
(self.stack)(rsc).children.push(overlay);
self.entries.push(OverlayEntry {
serial,
widget,
previous_target,
cancel,
blocks_prior_input,
});
rsc.set_command_target(Some(id));
OverlayHandle {
host: id.host(),
kind: OverlayKind::Stackable,
serial,
}
}
fn close<Rsc: HasEvents>(&mut self, id: ControllerId, serial: u64, rsc: &mut Rsc) -> bool {
let Some(index) = self.entries.iter().position(|entry| entry.serial == serial) else {
return false;
};
close_single_at(id.host(), rsc, false);
let previous_target = self.entries[index].previous_target;
for entry in self.entries.drain(index..) {
remove_child(self.stack, entry.widget, rsc);
}
if rsc.events().controllers.command_target() == Some(id) {
rsc.set_command_target(previous_target);
}
true
}
fn take_serial(&mut self) -> u64 {
let serial = self.next_serial;
self.next_serial = self.next_serial.wrapping_add(1).max(1);
serial
}
}
impl<Rsc: HasEvents> Controller<Rsc> for StackableOverlayController {
fn command(&mut self, command: Command, rsc: &mut Rsc) -> CommandResult {
if command != Command::Escape {
return CommandResult::Unused;
}
let mut used = false;
loop {
let Some(entry) = self.entries.pop() else {
return if used {
CommandResult::Used
} else {
CommandResult::Unused
};
};
used = true;
remove_child(self.stack, entry.widget, rsc);
rsc.set_command_target(entry.previous_target);
if entry.cancel == CancelBehavior::Consume {
return CommandResult::Used;
}
let same_controller = entry.previous_target.is_some_and(|previous| {
previous.host() == self.stack.id() && previous.is::<StackableOverlayController>()
});
if !same_controller {
let _ = rsc.run_command(command);
return CommandResult::Used;
}
}
}
fn blocks_prior_input(&self) -> bool {
self.entries
.last()
.is_some_and(|entry| entry.blocks_prior_input)
}
}
fn remove_child<Rsc: UiRsc>(stack: WeakWidget<Stack>, widget: WidgetId, rsc: &mut Rsc) {
if let Some(stack) = rsc.widgets_mut().get_mut(&stack)
&& let Some(index) = stack.children.iter().position(|child| child.id() == widget)
{
stack.children.remove(index);
}
}
fn close_single_at<Rsc: HasEvents>(host: WidgetId, rsc: &mut Rsc, replacement: bool) {
let Some(id) = rsc.events().controllers.id::<SingleOverlayController>(host) else {
return;
};
rsc.with_controller(id, |single: &mut SingleOverlayController, rsc| {
single.dismiss(id, rsc, replacement);
});
}
fn add_host<Rsc: HasEvents>(
rsc: &mut Rsc,
base: StrongWidget,
options: OverlayHostOptions,
) -> WeakWidget<Stack> {
let stack = Stack {
children: vec![base],
size: StackSize::Default,
}
.add(rsc);
if options.single {
rsc.register_controller(stack, SingleOverlayController::new(stack));
}
if options.stackable {
rsc.register_controller(stack, StackableOverlayController::new(stack));
}
stack
}
pub fn overlay_host<Rsc, W, Tag>(
content: W,
options: OverlayHostOptions,
) -> impl WidgetIdFn<Rsc, Stack>
where
Rsc: HasEvents,
W: WidgetLike<Rsc, Tag>,
{
move |rsc: &mut Rsc| {
let content = content.add_strong(rsc);
add_host(rsc, content, options)
}
}
pub(crate) fn default_overlay_root<Rsc: HasEvents>(
rsc: &mut Rsc,
root: StrongWidget,
) -> StrongWidget {
add_host(rsc, root, OverlayHostOptions::BOTH)
.upgrade(rsc)
.any()
}
pub trait OverlayRscExt: HasEvents {
fn open_popup<W, Tag>(&mut self, origin: impl IdLike, popup: W) -> Option<OverlayHandle>
where
W: WidgetLike<Self, Tag>,
{
self.open_overlay(
origin,
popup,
OverlayOptions::single(CancelBehavior::PassThrough),
)
}
fn open_modal<W, Tag>(&mut self, origin: impl IdLike, modal: W) -> Option<OverlayHandle>
where
W: WidgetLike<Self, Tag>,
{
let (target, path) = self
.events()
.controllers
.path_to::<StackableOverlayController>(origin.id())?;
clear_singles(&path, self);
let content = modal.add(self);
let content_strong = content.upgrade(self).any();
let backdrop = rect(Srgba8::new(0, 0, 0, 96)).add(self);
let backdrop_strong = backdrop.upgrade(self).any();
let centered = Aligned {
inner: content_strong,
align: Align::CENTER.into(),
}
.add_strong(self)
.any();
let layer = Stack {
children: vec![backdrop_strong, centered],
size: StackSize::Default,
}
.add_strong(self)
.any();
let handle = open_stackable_at(target, layer, CancelBehavior::Consume, true, self)?;
let blocking = modal_pointer_senses();
self.register_event(content, blocking.clone(), |_, _| {});
self.register_event(backdrop, blocking, move |ctx, rsc| {
if matches!(ctx.data.sense, CursorSense::PressStart(_)) {
handle.close(rsc);
}
});
Some(handle)
}
fn open_overlay<W, Tag>(
&mut self,
origin: impl IdLike,
overlay: W,
options: OverlayOptions,
) -> Option<OverlayHandle>
where
W: WidgetLike<Self, Tag>,
{
match options.kind {
OverlayKind::Single => {
let target = self
.events()
.controllers
.nearest_id::<SingleOverlayController>(origin.id())?;
let overlay = overlay.add_strong(self).any();
self.with_controller(target, |single: &mut SingleOverlayController, rsc| {
single.open(
target,
overlay,
options.cancel,
options.blocks_prior_input,
rsc,
)
})
}
OverlayKind::Stackable => {
let (target, path) = self
.events()
.controllers
.path_to::<StackableOverlayController>(origin.id())?;
clear_singles(&path, self);
let overlay = overlay.add_strong(self).any();
open_stackable_at(
target,
overlay,
options.cancel,
options.blocks_prior_input,
self,
)
}
}
}
}
impl<Rsc: HasEvents> OverlayRscExt for Rsc {}
fn modal_pointer_senses() -> CursorSenses {
CursorSense::drag_senses()
| CursorSense::PressStart(CursorButton::Right)
| CursorSense::Pressing(CursorButton::Right)
| CursorSense::PressEnd(CursorButton::Right)
| CursorSense::PressStart(CursorButton::Middle)
| CursorSense::Pressing(CursorButton::Middle)
| CursorSense::PressEnd(CursorButton::Middle)
| CursorSense::Scroll(Axis::X)
| CursorSense::Scroll(Axis::Y)
}
fn clear_singles<Rsc: HasEvents>(path: &[WidgetId], rsc: &mut Rsc) {
let controllers: Vec<ControllerId> = path
.iter()
.filter_map(|&host| rsc.events().controllers.id::<SingleOverlayController>(host))
.collect();
for id in controllers {
rsc.with_controller(id, |single: &mut SingleOverlayController, rsc| {
single.dismiss(id, rsc, true);
});
}
}
fn open_stackable_at<Rsc: HasEvents>(
target: ControllerId,
overlay: StrongWidget,
cancel: CancelBehavior,
blocks_prior_input: bool,
rsc: &mut Rsc,
) -> Option<OverlayHandle> {
rsc.with_controller(target, |stackable: &mut StackableOverlayController, rsc| {
stackable.open(target, overlay, cancel, blocks_prior_input, rsc)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness::{Harness, HarnessRsc, TouchAction};
use std::{cell::Cell, rc::Rc};
struct EscapeCounter(Rc<Cell<usize>>);
impl Controller<HarnessRsc> for EscapeCounter {
fn command(&mut self, command: Command, _rsc: &mut HarnessRsc) -> CommandResult {
if command == Command::Escape {
self.0.set(self.0.get() + 1);
CommandResult::Used
} else {
CommandResult::Unused
}
}
}
fn stack_len(harness: &Harness, handle: OverlayHandle) -> usize {
harness
.rsc
.widgets()
.get_dyn(handle.host)
.unwrap()
.as_any()
.downcast_ref::<Stack>()
.unwrap()
.children
.len()
}
fn rooted_harness() -> (Harness, WeakWidget<Rect>, Rc<Cell<usize>>) {
let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0);
let base = rect(PaintId::WHITE).add(&mut harness.rsc);
let count = Rc::new(Cell::new(0));
harness
.rsc
.register_controller(base, EscapeCounter(count.clone()));
let target = harness
.rsc
.events()
.controllers
.id::<EscapeCounter>(base.id())
.unwrap();
harness.rsc.set_command_target(Some(target));
let root = base.upgrade(&mut harness.rsc).any();
harness.state.set_root(&mut harness.rsc, root);
harness.frame(0);
(harness, base, count)
}
#[test]
fn popup_is_single_and_passes_escape_through() {
let (mut harness, base, escapes) = rooted_harness();
let first = harness.rsc.open_popup(base, rect(PaintId::RED)).unwrap();
assert_eq!(stack_len(&harness, first), 2);
let second = harness.rsc.open_popup(base, rect(PaintId::BLUE)).unwrap();
assert_eq!(escapes.get(), 1, "replacement reaches the old owner");
assert!(!first.close(&mut harness.rsc), "a replaced handle is stale");
assert_eq!(stack_len(&harness, second), 2);
assert_eq!(
harness.rsc.run_command(Command::Escape),
CommandResult::Used
);
assert_eq!(
escapes.get(),
1,
"the replacement had no prior command target"
);
assert_eq!(stack_len(&harness, second), 1);
}
#[test]
fn stackables_survive_singles_and_close_from_the_top() {
let (mut harness, base, escapes) = rooted_harness();
let options = OverlayOptions::stackable(CancelBehavior::Consume);
let first = harness
.rsc
.open_overlay(base, rect(PaintId::RED), options)
.unwrap();
let single = harness.rsc.open_popup(base, rect(PaintId::GREEN)).unwrap();
let second = harness
.rsc
.open_overlay(base, rect(PaintId::BLUE), options)
.unwrap();
assert!(!single.close(&mut harness.rsc));
assert_eq!(stack_len(&harness, second), 3);
assert_eq!(
harness.rsc.run_command(Command::Escape),
CommandResult::Used
);
assert_eq!(stack_len(&harness, first), 2);
assert_eq!(
harness.rsc.run_command(Command::Escape),
CommandResult::Used
);
assert_eq!(stack_len(&harness, first), 1);
assert_eq!(escapes.get(), 0, "stackable cancellation is consumed");
assert_eq!(
harness.rsc.run_command(Command::Escape),
CommandResult::Used
);
assert_eq!(escapes.get(), 1);
}
#[test]
fn an_explicit_single_can_consume_cancellation() {
let (mut harness, base, escapes) = rooted_harness();
let overlay = harness
.rsc
.open_overlay(
base,
rect(PaintId::RED),
OverlayOptions::single(CancelBehavior::Consume),
)
.unwrap();
assert_eq!(
harness.rsc.run_command(Command::Escape),
CommandResult::Used
);
assert_eq!(escapes.get(), 0);
assert_eq!(stack_len(&harness, overlay), 1);
}
#[test]
fn a_stackable_can_pass_cancellation_to_the_entry_under_it() {
let (mut harness, base, escapes) = rooted_harness();
let first = harness
.rsc
.open_overlay(
base,
rect(PaintId::RED),
OverlayOptions::stackable(CancelBehavior::Consume),
)
.unwrap();
harness
.rsc
.open_overlay(
base,
rect(PaintId::BLUE),
OverlayOptions::stackable(CancelBehavior::PassThrough),
)
.unwrap();
assert_eq!(
harness.rsc.run_command(Command::Escape),
CommandResult::Used
);
assert_eq!(stack_len(&harness, first), 1);
assert_eq!(escapes.get(), 0);
}
#[test]
fn closing_a_stackable_handle_closes_everything_above_it() {
let (mut harness, base, _) = rooted_harness();
let options = OverlayOptions::stackable(CancelBehavior::Consume);
let first = harness
.rsc
.open_overlay(base, rect(PaintId::RED), options)
.unwrap();
let second = harness
.rsc
.open_overlay(base, rect(PaintId::BLUE), options)
.unwrap();
let single = harness.rsc.open_popup(base, rect(PaintId::GREEN)).unwrap();
assert!(first.close(&mut harness.rsc));
assert!(!second.close(&mut harness.rsc));
assert!(!single.close(&mut harness.rsc));
assert_eq!(stack_len(&harness, first), 1);
}
#[test]
fn backdrop_dismisses_modal_without_clicking_the_base() {
let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0);
let clicks = Rc::new(Cell::new(0));
let base = rect(PaintId::WHITE).add(&mut harness.rsc);
let counted = clicks.clone();
harness
.rsc
.register_event(base, CursorSense::click(), move |_, _| {
counted.set(counted.get() + 1);
});
let root = base.upgrade(&mut harness.rsc).any();
harness.state.set_root(&mut harness.rsc, root);
harness.frame(0);
let modal = harness
.rsc
.open_modal(base, rect(PaintId::RED).sized((80, 80)))
.unwrap();
harness.frame(1);
assert_eq!(stack_len(&harness, modal), 2);
assert!(
harness
.rsc
.events()
.controllers
.command_target_blocks_input()
);
harness.touch(TouchAction::Down, Vec2::new(100.0, 150.0), 2);
harness.touch(TouchAction::Up, Vec2::new(100.0, 150.0), 3);
assert_eq!(stack_len(&harness, modal), 2);
harness.touch(TouchAction::Down, Vec2::new(5.0, 5.0), 4);
assert_eq!(clicks.get(), 0);
assert_eq!(stack_len(&harness, modal), 1);
assert!(
!harness
.rsc
.events()
.controllers
.command_target_blocks_input()
);
}
#[test]
fn local_single_host_passes_a_modal_to_the_root() {
let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0);
let leaf = rect(PaintId::WHITE).add(&mut harness.rsc);
let local = overlay_host(leaf, OverlayHostOptions::SINGLE).add(&mut harness.rsc);
let root = local.upgrade(&mut harness.rsc).any();
harness.state.set_root(&mut harness.rsc, root);
harness.frame(0);
let popup = harness.rsc.open_popup(leaf, rect(PaintId::RED)).unwrap();
assert_eq!(popup.host, local.id());
let modal = harness
.rsc
.open_modal(leaf, rect(PaintId::BLUE).sized((80, 80)))
.unwrap();
assert!(!popup.close(&mut harness.rsc));
assert_ne!(modal.host, local.id());
}
#[test]
fn replacing_a_local_single_does_not_cancel_an_ancestor_overlay() {
let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0);
let leaf = rect(PaintId::WHITE).add(&mut harness.rsc);
let local = overlay_host(leaf, OverlayHostOptions::SINGLE).add(&mut harness.rsc);
let root = local.upgrade(&mut harness.rsc).any();
harness.state.set_root(&mut harness.rsc, root);
harness.frame(0);
let first_modal = harness
.rsc
.open_modal(leaf, rect(PaintId::RED).sized((80, 80)))
.unwrap();
harness.frame(1);
let popup = harness.rsc.open_popup(leaf, rect(PaintId::GREEN)).unwrap();
let second_modal = harness
.rsc
.open_modal(leaf, rect(PaintId::BLUE).sized((80, 80)))
.unwrap();
assert!(!popup.close(&mut harness.rsc));
assert_eq!(stack_len(&harness, first_modal), 3);
assert!(second_modal.close(&mut harness.rsc));
assert_eq!(stack_len(&harness, first_modal), 2);
}
#[test]
fn inactive_origins_have_no_overlay_route() {
let (mut harness, _, _) = rooted_harness();
let inactive = rect(PaintId::WHITE).add(&mut harness.rsc);
assert!(
harness
.rsc
.open_popup(inactive, rect(PaintId::RED))
.is_none()
);
}
#[test]
fn replacing_the_root_forgets_the_old_routing_tree() {
let (mut harness, old, _) = rooted_harness();
let new = rect(PaintId::WHITE).add(&mut harness.rsc);
let root = new.upgrade(&mut harness.rsc).any();
harness.state.set_root(&mut harness.rsc, root);
harness.frame(1);
assert!(harness.rsc.open_popup(old, rect(PaintId::RED)).is_none());
assert!(harness.rsc.open_popup(new, rect(PaintId::RED)).is_some());
}
}
+1 -1
View File
@@ -624,7 +624,7 @@ mod controller_tests {
let found = rsc let found = rsc
.events() .events()
.controllers .controllers
.nearest_id::<SelectionController>(leaf.id(), &render) .nearest_id::<SelectionController>(leaf.id())
.unwrap(); .unwrap();
assert_eq!(found.host(), inner.id()); assert_eq!(found.host(), inner.id());
} }
+1 -1
View File
@@ -5,7 +5,7 @@ pub struct ClientWidgets {
pub info: WeakWidget<Text>, pub info: WeakWidget<Text>,
} }
pub fn build<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> ClientWidgets pub fn build<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot<Rsc>) -> ClientWidgets
where where
Rsc::State: FocusHost, Rsc::State: FocusHost,
{ {
+1 -1
View File
@@ -30,7 +30,7 @@ fn solid_paints_and_images_round_trip_through_an_srgb_target() {
.span(Dir::RIGHT) .span(Dir::RIGHT)
.add_strong(&mut harness.rsc) .add_strong(&mut harness.rsc)
.any(); .any();
harness.state.set_root(root); harness.state.set_root(&mut harness.rsc, root);
harness.frame(0); harness.frame(0);
let mut renderer = let mut renderer =