Skip to main content

compose_rust/runtime/
effects.rs

1//! Effects: work that belongs to a place in the composition but must not run while it is
2//! being composed.
3//!
4//! Composing describes the screen and has no business doing anything else, because a
5//! scope can be run, skipped or run again at the runtime's discretion. What a composable
6//! wants done is queued while it composes and run after the composition is applied, on
7//! the UI thread, inside the same Host call: [`side_effect`] every time, [`launched_effect`]
8//! when its key changes, [`disposable_effect`] with a cleanup for when it leaves.
9//!
10//! Asynchronous work is a future polled on the UI thread by a small executor the runtime
11//! owns. A future that waits is woken from wherever its waker is called, a worker thread
12//! included, and waking asks the Renderer for a frame, the same single wake signal a state
13//! written from a worker uses. Domain work does not run on the UI thread: [`with_worker`]
14//! hands a closure to a worker thread and resumes the future with its result.
15
16use super::composer::{Composer, Effect, GroupId, RuntimeSignal};
17use super::{group_guard, with_composer, with_composer_if_idle};
18use std::any::Any;
19use std::cell::{Cell, RefCell};
20use std::future::Future;
21use std::pin::Pin;
22use std::rc::Rc;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, Mutex, Weak};
25use std::task::{Context, Poll, Wake, Waker};
26use std::time::Duration;
27
28type LocalFuture = Pin<Box<dyn Future<Output = ()>>>;
29
30/// One task: a future and the waker that queues it to be polled again.
31struct Task {
32    future: Option<LocalFuture>,
33    waker: Waker,
34}
35
36struct TaskWake {
37    id: usize,
38    queued: AtomicBool,
39    signal: Weak<RuntimeSignal>,
40}
41
42impl Wake for TaskWake {
43    fn wake(self: Arc<Self>) {
44        self.wake_by_ref();
45    }
46
47    fn wake_by_ref(self: &Arc<Self>) {
48        if self.queued.swap(true, Ordering::AcqRel) {
49            return;
50        }
51        if let Some(signal) = self.signal.upgrade() {
52            signal.push_woken(self.id);
53            if !crate::boundary::in_host_call() {
54                crate::boundary::request_frame_from_worker();
55            }
56        }
57    }
58}
59
60/// The futures the composition started, polled on the UI thread.
61#[derive(Default)]
62pub(crate) struct Executor {
63    tasks: Vec<Option<Task>>,
64    wakes: Vec<Option<Arc<TaskWake>>>,
65    free: Vec<usize>,
66    /// Futures waiting for the next frame, with the cell the frame time is left in.
67    frame_waiters: Vec<(Waker, Rc<Cell<Option<u64>>>)>,
68    /// The time of the frame being served, for the timers that count frames.
69    pub(crate) frame_time: Option<u64>,
70}
71
72impl Executor {
73    /// Starts a task. It is polled for the first time at the next opportunity in this call.
74    pub(crate) fn spawn(&mut self, future: LocalFuture, signal: &Arc<RuntimeSignal>) -> usize {
75        let id = self.free.pop().unwrap_or(self.tasks.len());
76        let wake = Arc::new(TaskWake {
77            id,
78            queued: AtomicBool::new(true),
79            signal: Arc::downgrade(signal),
80        });
81        let task = Task {
82            future: Some(future),
83            waker: Waker::from(Arc::clone(&wake)),
84        };
85        if id == self.tasks.len() {
86            self.tasks.push(Some(task));
87            self.wakes.push(Some(Arc::clone(&wake)));
88        } else {
89            self.tasks[id] = Some(task);
90            self.wakes[id] = Some(Arc::clone(&wake));
91        }
92        signal.push_woken(id);
93        id
94    }
95
96    /// Stops a task. Its future is dropped after the composer is released.
97    pub(crate) fn cancel(&mut self, id: usize, graveyard: &mut Vec<Box<dyn Any>>) {
98        if let Some(slot) = self.tasks.get_mut(id) {
99            if let Some(task) = slot.take() {
100                if let Some(future) = task.future {
101                    graveyard.push(Box::new(future));
102                }
103                self.wakes[id] = None;
104                self.free.push(id);
105            }
106        }
107    }
108
109    fn take_for_poll(&mut self, id: usize) -> Option<(LocalFuture, Waker)> {
110        let task = self.tasks.get_mut(id)?.as_mut()?;
111        let future = task.future.take()?;
112        if let Some(Some(wake)) = self.wakes.get(id) {
113            wake.queued.store(false, Ordering::Release);
114        }
115        Some((future, task.waker.clone()))
116    }
117
118    fn put_back(&mut self, id: usize, future: LocalFuture, graveyard: &mut Vec<Box<dyn Any>>) {
119        match self.tasks.get_mut(id).and_then(Option::as_mut) {
120            Some(task) => task.future = Some(future),
121            // Cancelled while it was being polled.
122            None => graveyard.push(Box::new(future)),
123        }
124    }
125
126    fn finish(&mut self, id: usize) {
127        if let Some(slot) = self.tasks.get_mut(id) {
128            if slot.take().is_some() {
129                self.wakes[id] = None;
130                self.free.push(id);
131            }
132        }
133    }
134
135    /// Hands every future waiting for a frame the frame's time, and wakes it.
136    pub(crate) fn deliver_frame(&mut self, frame_time_nanos: u64) {
137        self.frame_time = Some(frame_time_nanos);
138        for (waker, cell) in self.frame_waiters.drain(..) {
139            cell.set(Some(frame_time_nanos));
140            waker.wake();
141        }
142    }
143}
144
145impl Composer {
146    pub(crate) fn spawn(&mut self, future: LocalFuture) -> usize {
147        let signal = Arc::clone(&self.signal);
148        self.executor.spawn(future, &signal)
149    }
150}
151
152/// Polls every task that has been woken, until none is left woken. Called with the
153/// composer free, because a future runs application code.
154pub(crate) fn poll_woken() {
155    for _ in 0..64 {
156        let woken = with_composer(|composer| {
157            std::mem::take(
158                &mut *composer
159                    .signal
160                    .woken
161                    .lock()
162                    .unwrap_or_else(|poison| poison.into_inner()),
163            )
164        })
165        .unwrap_or_default();
166        if woken.is_empty() {
167            return;
168        }
169        for id in woken {
170            let Some((mut future, waker)) =
171                with_composer(|composer| composer.executor.take_for_poll(id)).flatten()
172            else {
173                continue;
174            };
175            let mut context = Context::from_waker(&waker);
176            match future.as_mut().poll(&mut context) {
177                Poll::Ready(()) => {
178                    with_composer(|composer| {
179                        composer.executor.finish(id);
180                        composer.graveyard.push(Box::new(future));
181                    });
182                }
183                Poll::Pending => {
184                    with_composer(|composer| {
185                        let Composer {
186                            executor,
187                            graveyard,
188                            ..
189                        } = composer;
190                        executor.put_back(id, future, graveyard);
191                    });
192                }
193            }
194        }
195        super::drain_graveyard();
196    }
197}
198
199// ----- the effect slots ------------------------------------------------------------------
200
201/// Whether a launched effect is still wanted, and the task it became.
202#[derive(Default)]
203pub(crate) struct LaunchHandle {
204    pub(crate) task: Cell<Option<usize>>,
205    pub(crate) cancelled: Cell<bool>,
206}
207
208/// A disposable effect's cleanup, once it has run.
209#[derive(Default)]
210pub(crate) struct DisposeHandle {
211    on_dispose: RefCell<Option<Box<dyn FnOnce()>>>,
212    cancelled: Cell<bool>,
213}
214
215/// The tasks a coroutine scope started, cancelled with it.
216#[derive(Default)]
217pub(crate) struct CoroutineState {
218    tasks: RefCell<Vec<usize>>,
219    cancelled: Cell<bool>,
220}
221
222/// What an effect keeps in its call site's slot.
223pub(crate) enum EffectSlot {
224    Launched {
225        key: Box<dyn Any>,
226        handle: Rc<LaunchHandle>,
227    },
228    Disposable {
229        key: Box<dyn Any>,
230        handle: Rc<DisposeHandle>,
231    },
232    Coroutines(Rc<CoroutineState>),
233}
234
235impl EffectSlot {
236    /// The call site left the composition, or its key changed: whatever it started stops,
237    /// and a cleanup it registered runs after the composition is applied.
238    pub(crate) fn dispose(&mut self, composer: &mut Composer) {
239        match self {
240            Self::Launched { handle, .. } => {
241                handle.cancelled.set(true);
242                if let Some(task) = handle.task.take() {
243                    let Composer {
244                        executor,
245                        graveyard,
246                        ..
247                    } = composer;
248                    executor.cancel(task, graveyard);
249                }
250            }
251            Self::Disposable { handle, .. } => {
252                handle.cancelled.set(true);
253                if let Some(cleanup) = handle.on_dispose.borrow_mut().take() {
254                    composer.disposals.push(cleanup);
255                }
256            }
257            Self::Coroutines(state) => {
258                state.cancelled.set(true);
259                let tasks = std::mem::take(&mut *state.tasks.borrow_mut());
260                let Composer {
261                    executor,
262                    graveyard,
263                    ..
264                } = composer;
265                for task in tasks {
266                    executor.cancel(task, graveyard);
267                }
268            }
269        }
270    }
271}
272
273const LAUNCHED_EFFECT: u64 = super::composer::call_site("compose_rust::LaunchedEffect", 0);
274const DISPOSABLE_EFFECT: u64 = super::composer::call_site("compose_rust::DisposableEffect", 0);
275const COROUTINE_SCOPE: u64 =
276    super::composer::call_site("compose_rust::remember_coroutine_scope", 0);
277
278/// Whether the current slot is an effect of this kind whose key equals `key`. Advances
279/// past the slot when it is.
280fn same_key<K: PartialEq + 'static>(composer: &mut Composer, launched: bool, key: &K) -> bool {
281    let same = match composer.peek_slot_mut::<EffectSlot>() {
282        Some(EffectSlot::Launched { key: old, .. }) if launched => {
283            old.downcast_ref::<K>() == Some(key)
284        }
285        Some(EffectSlot::Disposable { key: old, .. }) if !launched => {
286            old.downcast_ref::<K>() == Some(key)
287        }
288        _ => false,
289    };
290    if same {
291        composer.slot_mut::<EffectSlot>();
292    }
293    same
294}
295
296/// Runs `block`'s future when this call site enters the composition, and again, after
297/// cancelling the last one, whenever `key` changes. Cancelled when the call site leaves.
298/// Compose's `LaunchedEffect`.
299///
300/// The future is polled on the UI thread. It may wait as long as it likes: waiting costs
301/// nothing, and whatever wakes it asks for a frame. Work that takes time to compute goes
302/// through [`with_worker`] so that the UI thread only ever resumes with the answer.
303///
304/// ```ignore
305/// let seconds = remember(|| mutable_state_of(0));
306/// launched_effect((), move || async move {
307///     loop {
308///         delay(Duration::from_secs(1)).await;
309///         seconds.update(|seconds| *seconds += 1);
310///     }
311/// });
312/// ```
313pub fn launched_effect<K, F, Fut>(key: K, block: F)
314where
315    K: PartialEq + 'static,
316    F: FnOnce() -> Fut + 'static,
317    Fut: Future<Output = ()> + 'static,
318{
319    let _group = group_guard(LAUNCHED_EFFECT);
320    with_composer_if_idle(|composer| {
321        if !composer.composing() || same_key(composer, true, &key) {
322            return;
323        }
324        let handle = Rc::new(LaunchHandle::default());
325        composer.remember_new(EffectSlot::Launched {
326            key: Box::new(key),
327            handle: Rc::clone(&handle),
328        });
329        composer.push_effect(Effect::Launch {
330            handle,
331            start: Box::new(move || Box::pin(block()) as LocalFuture),
332        });
333    });
334}
335
336/// What a [`disposable_effect`] leaves behind: the cleanup to run when it leaves.
337pub struct DisposableEffectResult {
338    cleanup: Box<dyn FnOnce()>,
339}
340
341/// The cleanup a [`disposable_effect`] returns. Compose's `onDispose`.
342pub fn on_dispose(cleanup: impl FnOnce() + 'static) -> DisposableEffectResult {
343    DisposableEffectResult {
344        cleanup: Box::new(cleanup),
345    }
346}
347
348/// Runs `effect` after this call site enters the composition and again whenever `key`
349/// changes, and runs the cleanup it returned before the next run and when the call site
350/// leaves. Compose's `DisposableEffect`.
351///
352/// ```ignore
353/// disposable_effect(channel.clone(), move || {
354///     let subscription = subscribe(&channel);
355///     on_dispose(move || drop(subscription))
356/// });
357/// ```
358pub fn disposable_effect<K, F>(key: K, effect: F)
359where
360    K: PartialEq + 'static,
361    F: FnOnce() -> DisposableEffectResult + 'static,
362{
363    let _group = group_guard(DISPOSABLE_EFFECT);
364    with_composer_if_idle(|composer| {
365        if !composer.composing() || same_key(composer, false, &key) {
366            return;
367        }
368        let handle = Rc::new(DisposeHandle::default());
369        composer.remember_new(EffectSlot::Disposable {
370            key: Box::new(key),
371            handle: Rc::clone(&handle),
372        });
373        composer.push_effect(Effect::Run(Box::new(move || {
374            if handle.cancelled.get() {
375                return;
376            }
377            let result = effect();
378            if handle.cancelled.get() {
379                (result.cleanup)();
380            } else {
381                *handle.on_dispose.borrow_mut() = Some(result.cleanup);
382            }
383        })));
384    });
385}
386
387/// Runs `effect` after every composition of the scope it is in that is applied. Compose's
388/// `SideEffect`: how a composable publishes something to an object the composition does
389/// not manage.
390pub fn side_effect(effect: impl FnOnce() + 'static) {
391    with_composer_if_idle(|composer| {
392        if composer.composing() {
393            composer.push_effect(Effect::Run(Box::new(effect)));
394        }
395    });
396}
397
398/// A place to start futures from an event handler, whose futures are cancelled when the
399/// call site that remembered it leaves the composition. Compose's `CoroutineScope`.
400#[derive(Clone)]
401pub struct CoroutineScope {
402    state: Rc<CoroutineState>,
403}
404
405impl CoroutineScope {
406    /// Starts `future` on the UI thread's executor. It is polled for the first time before
407    /// the Host call that started it returns.
408    pub fn launch(&self, future: impl Future<Output = ()> + 'static) {
409        if self.state.cancelled.get() {
410            return;
411        }
412        let id = with_composer_if_idle(|composer| composer.spawn(Box::pin(future)));
413        if let Some(id) = id {
414            let mut tasks = self.state.tasks.borrow_mut();
415            tasks.push(id);
416        }
417    }
418}
419
420/// A coroutine scope bound to this call site. Compose's `rememberCoroutineScope`.
421///
422/// ```ignore
423/// let scope = remember_coroutine_scope();
424/// Button("Refresh").on_click(move || scope.launch(async move {
425///     let fresh = with_worker(fetch).await;
426///     items.set(fresh);
427/// }));
428/// ```
429pub fn remember_coroutine_scope() -> CoroutineScope {
430    let _group = group_guard(COROUTINE_SCOPE);
431    let state = with_composer_if_idle(|composer| {
432        if !composer.composing() {
433            return None;
434        }
435        if let Some(EffectSlot::Coroutines(state)) = composer.slot_mut::<EffectSlot>() {
436            return Some(Rc::clone(state));
437        }
438        let state = Rc::new(CoroutineState::default());
439        composer.remember_new(EffectSlot::Coroutines(Rc::clone(&state)));
440        Some(state)
441    })
442    .flatten();
443    CoroutineScope {
444        state: state.unwrap_or_default(),
445    }
446}
447
448/// Runs the effects a composition queued: cleanups first, then new effects, as Compose
449/// runs forgotten observers before remembered ones. Called with the composer free.
450pub(crate) fn apply_effects() {
451    let (disposals, effects) = with_composer(|composer| {
452        (
453            std::mem::take(&mut composer.disposals),
454            std::mem::take(&mut composer.effects),
455        )
456    })
457    .unwrap_or_default();
458    for cleanup in disposals {
459        cleanup();
460    }
461    for effect in effects {
462        match effect {
463            Effect::Run(run) => run(),
464            Effect::Launch { handle, start } => {
465                if handle.cancelled.get() {
466                    continue;
467                }
468                let future = start();
469                let id = with_composer(|composer| composer.spawn(future));
470                if let Some(id) = id {
471                    handle.task.set(Some(id));
472                }
473            }
474        }
475    }
476}
477
478// ----- futures ------------------------------------------------------------------------------
479
480/// Resolves on the next frame with that frame's time, in nanoseconds. Compose's
481/// `withFrameNanos`, the clock animations run on.
482pub fn with_frame_nanos<R>(on_frame: impl FnOnce(u64) -> R) -> impl Future<Output = R> {
483    FrameFuture {
484        on_frame: Some(on_frame),
485        cell: None,
486    }
487}
488
489struct FrameFuture<F> {
490    on_frame: Option<F>,
491    cell: Option<Rc<Cell<Option<u64>>>>,
492}
493
494impl<F> Unpin for FrameFuture<F> {}
495
496impl<R, F: FnOnce(u64) -> R> Future for FrameFuture<F> {
497    type Output = R;
498
499    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<R> {
500        if let Some(time) = self.cell.as_ref().and_then(|cell| cell.get()) {
501            let on_frame = self.on_frame.take().expect("a frame future resolves once");
502            return Poll::Ready(on_frame(time));
503        }
504        let cell = Rc::clone(self.cell.get_or_insert_with(Rc::default));
505        let waker = context.waker().clone();
506        let registered = with_composer(|composer| {
507            composer.executor.frame_waiters.push((waker, cell));
508        });
509        if registered.is_some() {
510            crate::boundary::request_frame_from_worker();
511        }
512        Poll::Pending
513    }
514}
515
516/// Resolves after `duration`. Compose's `delay`.
517///
518/// The wait happens on a timer thread, so the UI thread does nothing while it lasts and
519/// the frame that resumes the future is asked for when it ends. In a browser, which has no
520/// threads here, the wait is counted on the frame clock instead.
521pub fn delay(duration: Duration) -> impl Future<Output = ()> {
522    Delay {
523        duration,
524        state: DelayState::Unstarted,
525    }
526}
527
528enum DelayState {
529    Unstarted,
530    #[cfg(not(target_family = "wasm"))]
531    Waiting(std::time::Instant),
532    #[cfg(target_family = "wasm")]
533    Counting(Option<u64>),
534}
535
536struct Delay {
537    duration: Duration,
538    state: DelayState,
539}
540
541impl Future for Delay {
542    type Output = ();
543
544    #[cfg(not(target_family = "wasm"))]
545    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
546        let deadline = match self.state {
547            DelayState::Waiting(deadline) => deadline,
548            DelayState::Unstarted => {
549                let deadline = std::time::Instant::now() + self.duration;
550                self.state = DelayState::Waiting(deadline);
551                deadline
552            }
553        };
554        if std::time::Instant::now() >= deadline {
555            return Poll::Ready(());
556        }
557        timer::register(deadline, context.waker().clone());
558        Poll::Pending
559    }
560
561    #[cfg(target_family = "wasm")]
562    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
563        let now = with_composer(|composer| composer.executor.frame_time).flatten();
564        let start = match self.state {
565            DelayState::Counting(Some(start)) => Some(start),
566            _ => now,
567        };
568        self.state = DelayState::Counting(start);
569        if let (Some(start), Some(now)) = (start, now) {
570            if now.saturating_sub(start) >= self.duration.as_nanos() as u64 {
571                return Poll::Ready(());
572            }
573        }
574        let cell = Rc::new(Cell::new(None));
575        let waker = context.waker().clone();
576        with_composer(|composer| composer.executor.frame_waiters.push((waker, cell)));
577        crate::boundary::request_frame_from_worker();
578        Poll::Pending
579    }
580}
581
582#[cfg(not(target_family = "wasm"))]
583mod timer {
584    use std::sync::{Condvar, Mutex, OnceLock};
585    use std::task::Waker;
586    use std::time::Instant;
587
588    struct Timer {
589        waiting: Mutex<Vec<(Instant, Waker)>>,
590        changed: Condvar,
591    }
592
593    static TIMER: OnceLock<&'static Timer> = OnceLock::new();
594
595    fn timer() -> &'static Timer {
596        TIMER.get_or_init(|| {
597            let timer: &'static Timer = Box::leak(Box::new(Timer {
598                waiting: Mutex::new(Vec::new()),
599                changed: Condvar::new(),
600            }));
601            std::thread::Builder::new()
602                .name("compose-rust-timer".to_owned())
603                .spawn(move || run(timer))
604                .expect("the timer thread could not be started");
605            timer
606        })
607    }
608
609    /// Wakes `waker` at `deadline`. One thread serves every delay in the process.
610    pub(super) fn register(deadline: Instant, waker: Waker) {
611        let timer = timer();
612        timer
613            .waiting
614            .lock()
615            .unwrap_or_else(|poison| poison.into_inner())
616            .push((deadline, waker));
617        timer.changed.notify_one();
618    }
619
620    fn run(timer: &'static Timer) {
621        let mut waiting = timer
622            .waiting
623            .lock()
624            .unwrap_or_else(|poison| poison.into_inner());
625        loop {
626            let now = Instant::now();
627            let mut index = 0;
628            while index < waiting.len() {
629                if waiting[index].0 <= now {
630                    let (_, waker) = waiting.swap_remove(index);
631                    waker.wake();
632                } else {
633                    index += 1;
634                }
635            }
636            let next = waiting.iter().map(|(deadline, _)| *deadline).min();
637            waiting = match next {
638                Some(deadline) => {
639                    timer
640                        .changed
641                        .wait_timeout(waiting, deadline.saturating_duration_since(now))
642                        .unwrap_or_else(|poison| poison.into_inner())
643                        .0
644                }
645                None => timer
646                    .changed
647                    .wait(waiting)
648                    .unwrap_or_else(|poison| poison.into_inner()),
649            };
650        }
651    }
652}
653
654/// Runs `work` on a worker thread and resolves with its result on the UI thread.
655/// Compose's `withContext(Dispatchers.IO)`: the way domain work leaves the UI thread.
656///
657/// In a browser, which has no threads here, `work` runs where the future is first polled.
658pub fn with_worker<R, W>(work: W) -> impl Future<Output = R>
659where
660    R: Send + 'static,
661    W: FnOnce() -> R + Send + 'static,
662{
663    WorkerFuture {
664        work: Some(work),
665        shared: Arc::new(Mutex::new((None, None))),
666    }
667}
668
669type WorkerShared<R> = Arc<Mutex<(Option<R>, Option<Waker>)>>;
670
671struct WorkerFuture<R, W> {
672    work: Option<W>,
673    shared: WorkerShared<R>,
674}
675
676impl<R, W> Unpin for WorkerFuture<R, W> {}
677
678impl<R, W> Future for WorkerFuture<R, W>
679where
680    R: Send + 'static,
681    W: FnOnce() -> R + Send + 'static,
682{
683    type Output = R;
684
685    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<R> {
686        if let Some(work) = self.work.take() {
687            #[cfg(target_family = "wasm")]
688            {
689                return Poll::Ready(work());
690            }
691            #[cfg(not(target_family = "wasm"))]
692            {
693                let shared = Arc::clone(&self.shared);
694                shared.lock().unwrap_or_else(|poison| poison.into_inner()).1 =
695                    Some(context.waker().clone());
696                std::thread::Builder::new()
697                    .name("compose-rust-worker".to_owned())
698                    .spawn(move || {
699                        let result = work();
700                        let waker = {
701                            let mut guard =
702                                shared.lock().unwrap_or_else(|poison| poison.into_inner());
703                            guard.0 = Some(result);
704                            guard.1.take()
705                        };
706                        if let Some(waker) = waker {
707                            waker.wake();
708                        }
709                    })
710                    .expect("a worker thread could not be started");
711                return Poll::Pending;
712            }
713        }
714        let mut guard = self
715            .shared
716            .lock()
717            .unwrap_or_else(|poison| poison.into_inner());
718        match guard.0.take() {
719            Some(result) => Poll::Ready(result),
720            None => {
721                guard.1 = Some(context.waker().clone());
722                Poll::Pending
723            }
724        }
725    }
726}
727
728/// The group an effect's slot belongs to, for tests and for the Host.
729#[allow(dead_code)]
730pub(crate) fn current_group() -> Option<GroupId> {
731    with_composer_if_idle(|composer| composer.current_group()).flatten()
732}