Scope mod camera targets to their running owner
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -11706,7 +11706,7 @@ impl Game<'_> {
|
||||
let main_view = self.mod_main_view(alpha);
|
||||
// AirDef fork: a cut starts the temporal histories over; a film hears from the camera's
|
||||
// orientation (the sounds are already placed round the player).
|
||||
let view_id = main_view.as_ref().map(|v| v.id);
|
||||
let view_id = main_view.as_ref().map(|v| (v.owner, v.id));
|
||||
if view_id != self.film.last_view {
|
||||
self.film.last_view = view_id;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
@@ -11993,7 +11993,7 @@ impl Game<'_> {
|
||||
let mod_views: Vec<_> = self
|
||||
.mod_views(alpha)
|
||||
.into_iter()
|
||||
.filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id))
|
||||
.filter(|v| main_view.as_ref().is_none_or(|m| (m.owner, m.id) != (v.owner, v.id)))
|
||||
.collect();
|
||||
let hd_lights = self.mod_lights(camera, alpha);
|
||||
let hd_spots = self.mod_spots(camera, alpha);
|
||||
|
||||
@@ -93,7 +93,7 @@ pub(super) struct Film {
|
||||
outcome: Arc<Mutex<Option<Outcome>>>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
/// The shot the last frame showed (a change is a cut: warm-up frames follow).
|
||||
view: Option<u32>,
|
||||
view: Option<(u64, u32)>,
|
||||
/// Audio frames mixed so far.
|
||||
mixed: u64,
|
||||
/// Where the film's own clock (`film_clock`) began: real time at the first frame.
|
||||
@@ -109,7 +109,7 @@ pub(super) struct FilmState {
|
||||
pub(super) film: Option<Film>,
|
||||
pub(super) report: FilmReport,
|
||||
/// The main view of the last rendered frame (a cut resets the temporal histories).
|
||||
pub(super) last_view: Option<u32>,
|
||||
pub(super) last_view: Option<(u64, u32)>,
|
||||
}
|
||||
|
||||
/// An `ffmpeg` to run: `HORIZON_FFMPEG`, the `PATH`, or where Homebrew and packages put it (a
|
||||
|
||||
@@ -266,6 +266,8 @@ impl Game<'_> {
|
||||
/// The canvas: a dark ground under the mod's shapes.
|
||||
pub(super) fn draw_canvas(&self, batch: &mut HudBatch, owner: usize) {
|
||||
batch.fill(&self.gui.atlas, 0.0, 0.0, batch.width, batch.height, 0xFF0A100C);
|
||||
self.draw_shapes(batch, self.mods.mods.canvas_shapes(owner), (0.0, 0.0));
|
||||
if let Some(run) = self.mods.mods.run_serial(owner) {
|
||||
self.draw_shapes(batch, run, self.mods.mods.canvas_shapes(owner), (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub(super) struct ModModels {
|
||||
/// The version of the mod post pass the renderer has (0: none).
|
||||
post: u64,
|
||||
/// The mods' cameras by id: a tick ago and now.
|
||||
views: Vec<(CameraView, CameraView)>,
|
||||
views: Vec<(u64, CameraView, CameraView)>,
|
||||
/// The mods' lights by (mod, id): a tick ago and now.
|
||||
lights: HashMap<(usize, u32), (PointLight, PointLight)>,
|
||||
/// The mods' spot lights by (mod, id): a tick ago and now.
|
||||
@@ -187,18 +187,13 @@ impl Game<'_> {
|
||||
spots.insert(key, (before, *l));
|
||||
}
|
||||
self.mods.models.spots = spots;
|
||||
let views: Vec<CameraView> = self.mods.mods.views().map(|(_, v)| *v).collect();
|
||||
let views: Vec<(u64, CameraView)> = self.mods.mods.views().map(|(owner, v)| (owner, *v)).collect();
|
||||
let old = std::mem::take(&mut self.mods.models.views);
|
||||
self.mods.models.views = views
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
let before = old
|
||||
.iter()
|
||||
.find(|(_, o)| o.id == v.id)
|
||||
.map_or(v, |(_, o)| *o);
|
||||
(before, v)
|
||||
})
|
||||
.collect();
|
||||
self.mods.models.views = views.into_iter().map(|(owner, v)| {
|
||||
let before = old.iter().find(|(o, _, previous)| *o == owner && previous.id == v.id)
|
||||
.map_or(v, |(_, _, previous)| *previous);
|
||||
(owner, before, v)
|
||||
}).collect();
|
||||
}
|
||||
|
||||
/// The HD lighting a mod asked for, if any.
|
||||
@@ -370,10 +365,11 @@ impl Game<'_> {
|
||||
.models
|
||||
.views
|
||||
.iter()
|
||||
.map(|(a, b)| {
|
||||
.map(|(owner, a, b)| {
|
||||
// The short way round.
|
||||
let turn = (b.yaw - a.yaw + 540.0).rem_euclid(360.0) - 180.0;
|
||||
horizon_client::gfx::ModView {
|
||||
owner: *owner,
|
||||
id: b.id,
|
||||
camera: DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t),
|
||||
yaw: a.yaw + turn * t as f32,
|
||||
@@ -387,8 +383,8 @@ impl Game<'_> {
|
||||
|
||||
/// The camera a mod asked the screen's world from (`render.set-main-view`), for this frame.
|
||||
pub(super) fn mod_main_view(&self, alpha: f64) -> Option<horizon_client::gfx::ModView> {
|
||||
let id = self.mods.mods.main_view()?;
|
||||
self.mod_views(alpha).into_iter().find(|v| v.id == id)
|
||||
let (owner, id) = self.mods.mods.owned_main_view()?;
|
||||
self.mod_views(alpha).into_iter().find(|v| v.owner == owner && v.id == id)
|
||||
}
|
||||
|
||||
/// Draws every mod model between its last two poses, lit by the world
|
||||
|
||||
@@ -86,12 +86,12 @@ impl Game<'_> {
|
||||
impl Game<'_> {
|
||||
/// The running mods' HUD shapes, in order, over the HUD.
|
||||
pub(super) fn draw_mod_shapes(&self, batch: &mut HudBatch) {
|
||||
for shape in self.mods.mods.hud_shapes() {
|
||||
self.draw_shapes(batch, std::slice::from_ref(shape), (0.0, 0.0));
|
||||
for (owner, shape) in self.mods.mods.owned_hud_shapes() {
|
||||
self.draw_shapes(batch, owner, std::slice::from_ref(shape), (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
/// Shapes with their GUI coordinates moved by `(ox, oy)`.
|
||||
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, shapes: &[Shape], (ox, oy): (f32, f32)) {
|
||||
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, owner: u64, shapes: &[Shape], (ox, oy): (f32, f32)) {
|
||||
let atlas = &self.gui.atlas;
|
||||
for shape in shapes {
|
||||
match shape {
|
||||
@@ -123,6 +123,7 @@ impl Game<'_> {
|
||||
Shape::View(v) => {
|
||||
let r = v.rect;
|
||||
batch.views.push(horizon_client::hud::HudView {
|
||||
owner,
|
||||
at: batch.vertices.len() as u32,
|
||||
id: v.view,
|
||||
rect: [ox + r.x, oy + r.y, r.w, r.h],
|
||||
|
||||
@@ -378,7 +378,7 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn mods_acoustics_prepared(&mut self) {}
|
||||
pub(super) fn mods_film_report(&mut self, _report: &super::film::FilmReport) {}
|
||||
pub(super) fn mods_main_view_id(&self) -> Option<u32> {
|
||||
pub(super) fn mods_main_view_id(&self) -> Option<(u64, u32)> {
|
||||
None
|
||||
}
|
||||
pub(super) fn mod_command(&mut self, _command: &str) -> bool {
|
||||
@@ -925,8 +925,8 @@ impl Game<'_> {
|
||||
message: r.message.clone(),
|
||||
});
|
||||
}
|
||||
pub(super) fn mods_main_view_id(&self) -> Option<u32> {
|
||||
self.mods.mods.main_view()
|
||||
pub(super) fn mods_main_view_id(&self) -> Option<(u64, u32)> {
|
||||
self.mods.mods.owned_main_view()
|
||||
}
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
self.reload_dev_mods();
|
||||
@@ -1182,7 +1182,7 @@ impl Game<'_> {
|
||||
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
|
||||
}
|
||||
// Capability `render`'s corner panels, placed the same way.
|
||||
for panel in self.mods.mods.corner_panels() {
|
||||
for (owner, panel) in self.mods.mods.owned_corner_panels() {
|
||||
let corner = match panel.corner {
|
||||
horizon_mods::RenderCorner::TopLeft => Corner::TopLeft,
|
||||
horizon_mods::RenderCorner::TopRight => Corner::TopRight,
|
||||
@@ -1193,7 +1193,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let at = corners.place(corner, panel.width, panel.height);
|
||||
self.draw_shapes(batch, &panel.shapes, at);
|
||||
self.draw_shapes(batch, owner, &panel.shapes, at);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3424,7 +3424,7 @@ impl Renderer {
|
||||
self.uploads
|
||||
.buffer(encoder, &views.main, 0, bytemuck::bytes_of(globals));
|
||||
for d in due {
|
||||
let Some(t) = views.targets.get(&d.id) else {
|
||||
let Some(t) = views.targets.get(&(d.owner, d.id)) else {
|
||||
continue;
|
||||
};
|
||||
let own = Globals {
|
||||
@@ -3452,7 +3452,7 @@ impl Renderer {
|
||||
a: 1.0,
|
||||
};
|
||||
for d in due {
|
||||
let Some(t) = views.targets.get_mut(&d.id) else {
|
||||
let Some(t) = views.targets.get_mut(&(d.owner, d.id)) else {
|
||||
continue;
|
||||
};
|
||||
t.drawn = true;
|
||||
@@ -4583,6 +4583,7 @@ impl Renderer {
|
||||
}
|
||||
let n = (seen.len() + tiles.len()) as u32;
|
||||
views_due.push(mod_views::Due {
|
||||
owner: v.owner,
|
||||
id: v.id,
|
||||
view_proj,
|
||||
view,
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::HashMap;
|
||||
/// One camera of a mod, as the game interpolated it for this frame.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ModView {
|
||||
pub owner: u64,
|
||||
pub id: u32,
|
||||
pub camera: DVec3,
|
||||
/// Degrees, as the player's.
|
||||
@@ -20,6 +21,10 @@ pub struct ModView {
|
||||
|
||||
pub use crate::hud::HudView;
|
||||
|
||||
fn view_key(owner: u64, id: u32) -> (u64, u32) {
|
||||
(owner, id)
|
||||
}
|
||||
|
||||
/// The most cameras drawn a frame; the others wait their turn.
|
||||
pub(super) const PER_FRAME: usize = 3;
|
||||
/// Sections round a camera it draws, in chunks.
|
||||
@@ -56,6 +61,7 @@ pub(super) fn post_budget(size: [u32; 2]) -> Option<(usize, usize)> {
|
||||
|
||||
/// A camera drawn this frame.
|
||||
pub(super) struct Due {
|
||||
pub owner: u64,
|
||||
pub id: u32,
|
||||
/// Camera-relative to the player's camera, as the entities and the
|
||||
/// world's other geometry are.
|
||||
@@ -76,7 +82,7 @@ pub(super) struct Due {
|
||||
}
|
||||
|
||||
pub(super) struct Views {
|
||||
pub targets: HashMap<u32, Target>,
|
||||
pub targets: HashMap<(u64, u32), Target>,
|
||||
/// The player's camera's globals, copied back after the pictures.
|
||||
pub main: wgpu::Buffer,
|
||||
cursor: usize,
|
||||
@@ -86,6 +92,17 @@ pub(super) struct Views {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
/// Indices scheduled for this frame; IDs may coincide across runs.
|
||||
fn scheduled_indices(len: usize, cursor: &mut usize) -> Vec<usize> {
|
||||
if len == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = len.min(PER_FRAME);
|
||||
let start = *cursor % len;
|
||||
*cursor = cursor.wrapping_add(n);
|
||||
(0..n).map(|k| (start + k) % len).collect()
|
||||
}
|
||||
|
||||
const BLIT: &str = r#"
|
||||
@group(0) @binding(0) var picture: texture_2d<f32>;
|
||||
@group(0) @binding(1) var picture_sampler: sampler;
|
||||
@@ -217,20 +234,21 @@ impl Views {
|
||||
globals_size: u64,
|
||||
) -> Vec<&'a ModView> {
|
||||
self.targets
|
||||
.retain(|id, _| views.iter().any(|v| v.id == *id));
|
||||
.retain(|key, _| views.iter().any(|v| view_key(v.owner, v.id) == *key));
|
||||
for v in views {
|
||||
if self.targets.get(&v.id).is_none_or(|t| t.size != v.size) {
|
||||
if self
|
||||
.targets
|
||||
.get(&view_key(v.owner, v.id))
|
||||
.is_none_or(|t| t.size != v.size)
|
||||
{
|
||||
let t = self.target(device, v.size, globals_size);
|
||||
self.targets.insert(v.id, t);
|
||||
self.targets.insert(view_key(v.owner, v.id), t);
|
||||
}
|
||||
}
|
||||
if views.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = views.len().min(PER_FRAME);
|
||||
let start = self.cursor % views.len();
|
||||
self.cursor = self.cursor.wrapping_add(n);
|
||||
(0..n).map(|k| &views[(start + k) % views.len()]).collect()
|
||||
scheduled_indices(views.len(), &mut self.cursor)
|
||||
.into_iter()
|
||||
.map(|i| &views[i])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn target(&self, device: &wgpu::Device, size: [u32; 2], globals_size: u64) -> Target {
|
||||
@@ -315,7 +333,11 @@ impl Views {
|
||||
view: &HudView,
|
||||
scale: f32,
|
||||
) {
|
||||
let Some(t) = self.targets.get(&view.id).filter(|t| t.drawn) else {
|
||||
let Some(t) = self
|
||||
.targets
|
||||
.get(&view_key(view.owner, view.id))
|
||||
.filter(|t| t.drawn)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let [x, y, w, h] = view.rect.map(|v| v * scale);
|
||||
@@ -360,3 +382,42 @@ pub(super) fn matrices(v: &ModView, eye: DVec3, far: f32) -> (Mat4, Mat4, f32) {
|
||||
aspect,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod owner_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn identical_local_ids_resolve_distinct_targets_and_survive_other_owner_removal() {
|
||||
let mut cursor = 0;
|
||||
assert_eq!(scheduled_indices(2, &mut cursor), [0, 1]);
|
||||
assert!(scheduled_indices(0, &mut cursor).is_empty());
|
||||
let mut targets = HashMap::new();
|
||||
targets.insert(view_key(101, 7), "first texture");
|
||||
targets.insert(view_key(202, 7), "second texture");
|
||||
let first = HudView {
|
||||
owner: 101,
|
||||
id: 7,
|
||||
at: 0,
|
||||
rect: [0.0; 4],
|
||||
mode: 0,
|
||||
};
|
||||
let second = HudView {
|
||||
owner: 202,
|
||||
..first
|
||||
};
|
||||
assert_eq!(
|
||||
targets.get(&view_key(first.owner, first.id)),
|
||||
Some(&"first texture")
|
||||
);
|
||||
assert_eq!(
|
||||
targets.get(&view_key(second.owner, second.id)),
|
||||
Some(&"second texture")
|
||||
);
|
||||
targets.retain(|key, _| *key == view_key(second.owner, second.id));
|
||||
assert_eq!(
|
||||
targets.get(&view_key(second.owner, second.id)),
|
||||
Some(&"second texture")
|
||||
);
|
||||
assert!(targets.get(&view_key(first.owner, first.id)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +633,7 @@ pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU3
|
||||
/// vertex `at`, over what was drawn before.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct HudView {
|
||||
pub owner: u64,
|
||||
pub at: u32,
|
||||
pub id: u32,
|
||||
/// GUI pixels: x, y, width, height.
|
||||
|
||||
@@ -1288,6 +1288,17 @@ impl Mods {
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref()?.store.data().corner.as_ref())
|
||||
}
|
||||
/// Corner panels tagged with their running instance, for scoped camera IDs.
|
||||
pub fn owned_corner_panels(&self) -> impl Iterator<Item = (u64, &CornerPanel)> {
|
||||
self.entries.iter().filter_map(|e| {
|
||||
let state = e.run.as_ref()?.store.data();
|
||||
Some((state.run_serial, state.corner.as_ref()?))
|
||||
})
|
||||
}
|
||||
/// The current running instance for a canvas owner index.
|
||||
pub fn run_serial(&self, index: usize) -> Option<u64> {
|
||||
Some(self.entries.get(index)?.run.as_ref()?.store.data().run_serial)
|
||||
}
|
||||
/// Some running mod calls `game.raycast`: the snapshot needs a
|
||||
/// [`Snapshot::view_hit`].
|
||||
pub fn wants_raycast(&self) -> bool {
|
||||
@@ -1561,11 +1572,10 @@ impl Mods {
|
||||
})
|
||||
}
|
||||
/// Every running mod's cameras (AirDef fork), with the mod's index.
|
||||
pub fn views(&self) -> impl Iterator<Item = (usize, &CameraView)> {
|
||||
self.entries.iter().enumerate().flat_map(|(i, e)| {
|
||||
e.run
|
||||
.iter()
|
||||
.flat_map(move |r| r.store.data().views.iter().map(move |v| (i, v)))
|
||||
pub fn views(&self) -> impl Iterator<Item = (u64, &CameraView)> {
|
||||
self.entries.iter().filter_map(|e| e.run.as_ref()).flat_map(|r| {
|
||||
let state = r.store.data();
|
||||
state.views.iter().map(move |v| (state.run_serial, v))
|
||||
})
|
||||
}
|
||||
/// Every running mod's lights (AirDef fork), with the mod's index.
|
||||
@@ -1780,6 +1790,13 @@ impl Mods {
|
||||
.iter()
|
||||
.find_map(|e| e.run.as_ref()?.store.data().main_view)
|
||||
}
|
||||
/// The selected main view and the run whose ID namespace it belongs to.
|
||||
pub fn owned_main_view(&self) -> Option<(u64, u32)> {
|
||||
self.entries.iter().find_map(|e| {
|
||||
let state = e.run.as_ref()?.store.data();
|
||||
Some((state.run_serial, state.main_view?))
|
||||
})
|
||||
}
|
||||
/// The argument trees running client mods gave their registered commands
|
||||
/// (`command.set-tree`), for the chat to colour and complete; a tree
|
||||
/// for a name the mod did not register is left out.
|
||||
@@ -1920,6 +1937,13 @@ impl Mods {
|
||||
.filter_map(|e| e.run.as_ref())
|
||||
.flat_map(|r| &r.store.data().shapes)
|
||||
}
|
||||
/// HUD shapes tagged with the run that submitted them.
|
||||
pub fn owned_hud_shapes(&self) -> impl Iterator<Item = (u64, &Shape)> {
|
||||
self.entries.iter().filter_map(|e| e.run.as_ref()).flat_map(|r| {
|
||||
let state = r.store.data();
|
||||
state.shapes.iter().map(move |shape| (state.run_serial, shape))
|
||||
})
|
||||
}
|
||||
/// The screen mod `i` last pushed with `ui.open-screen`, while it is up.
|
||||
pub fn screen(&self, i: usize) -> Option<&UiScreen> {
|
||||
self.entries
|
||||
|
||||
@@ -110,6 +110,44 @@ fn player() -> Snapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_camera_id_from_two_mod_runs_keeps_its_owner() {
|
||||
let original = Package::read(&mods_dir().join("horizon_mods_test_ui_guest.hmod"))
|
||||
.unwrap()
|
||||
.wasm
|
||||
.unwrap();
|
||||
let dir = mods_dir().join("camera_owner");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let one = dir.join("one.hmod");
|
||||
let two = dir.join("two.hmod");
|
||||
std::fs::write(&one, Package::write(UI_MANIFEST, &original).unwrap()).unwrap();
|
||||
let other = UI_MANIFEST
|
||||
.replace("test_ui_guest", "test_ui_guest_other")
|
||||
.replace("Test UI Guest", "Other UI Guest");
|
||||
std::fs::write(&two, Package::write(&other, &original).unwrap()).unwrap();
|
||||
let mut mods = Mods::load(&[one, two], |_| true, |_| false);
|
||||
mods.set_snapshot(Snapshot {
|
||||
world: Some("test".into()),
|
||||
world_seed: Some(-987_654_321),
|
||||
..Snapshot::default()
|
||||
});
|
||||
mods.tick(1);
|
||||
let views: Vec<_> = mods.views().map(|(run, view)| (run, view.id)).collect();
|
||||
let hud: Vec<_> = mods
|
||||
.owned_hud_shapes()
|
||||
.filter_map(|(run, shape)| match shape {
|
||||
Shape::View(view) => Some((run, view.view)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(views.len(), 2, "{views:?}");
|
||||
assert_ne!(views[0].0, views[1].0);
|
||||
assert_eq!(views[0].1, 7);
|
||||
assert_eq!(views[1].1, 7);
|
||||
assert_eq!(hud, views);
|
||||
assert_eq!(mods.owned_main_view(), Some(views[0]));
|
||||
}
|
||||
|
||||
/// The guest submits changes; only the audio apply acknowledgement advances its status.
|
||||
#[test]
|
||||
fn sound_sources_status_tracks_apply_output_and_generation() {
|
||||
|
||||
@@ -666,6 +666,30 @@ impl Mod for Guest {
|
||||
Ok(None)
|
||||
}
|
||||
fn tick(&mut self, _tick: u64) {
|
||||
if game::world_seed() == Some(-987_654_321) {
|
||||
render::set_views(&[render::CameraView {
|
||||
id: 7,
|
||||
x: 1.0,
|
||||
y: 2.0,
|
||||
z: 3.0,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov: 70.0,
|
||||
width: 64,
|
||||
height: 64,
|
||||
}]);
|
||||
render::set_main_view(Some(7));
|
||||
render::set_hud(&[Shape::View(render::ViewShape {
|
||||
view: 7,
|
||||
rect: Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 64.0,
|
||||
h: 64.0,
|
||||
},
|
||||
mode: 0,
|
||||
})]);
|
||||
}
|
||||
if let Some((book, all, id)) = self.pending.take() {
|
||||
match recipes::place(book, all) {
|
||||
Err(e) if e.starts_with("no screen") => self.pending = Some((book, all, id)),
|
||||
|
||||
Reference in New Issue
Block a user